Initial commit: Pezkuwi Wallet Android

Security hardened release:
- Code obfuscation enabled (minifyEnabled=true, shrinkResources=true)
- Sensitive files excluded (google-services.json, keystores)
- Branch.io key moved to BuildConfig placeholder
- Updated dependencies: OkHttp 4.12.0, Gson 2.10.1, BouncyCastle 1.77
- Comprehensive ProGuard rules for crypto wallet
- Navigation 2.7.7, Lifecycle 2.7.0, ConstraintLayout 2.1.4
This commit is contained in:
2026-02-12 05:19:41 +03:00
commit a294aa1a6b
7687 changed files with 441811 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+65
View File
@@ -0,0 +1,65 @@
apply plugin: 'kotlin-parcelize'
apply from: '../tests.gradle'
android {
namespace 'io.novafoundation.nova.feature_settings_impl'
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation project(':common')
implementation project(':runtime')
implementation project(':feature-account-api')
implementation project(':feature-currency-api')
implementation project(':feature-wallet-connect-api')
implementation project(':feature-versions-api')
implementation project(':feature-push-notifications')
implementation project(':feature-assets')
implementation project(':caip')
implementation project(':feature-settings-api')
implementation project(':feature-cloud-backup-api')
implementation kotlinDep
implementation androidDep
implementation materialDep
implementation cardViewDep
implementation constraintDep
implementation shimmerDep
implementation biometricDep
implementation substrateSdkDep
implementation coroutinesDep
implementation coroutinesAndroidDep
implementation viewModelKtxDep
implementation liveDataKtxDep
implementation lifeCycleKtxDep
implementation daggerDep
ksp daggerCompiler
implementation roomDep
ksp roomCompiler
implementation lifecycleDep
ksp lifecycleCompiler
testImplementation jUnitDep
testImplementation mockitoDep
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,51 @@
package io.novafoundation.nova.feature_settings_impl
import io.novafoundation.nova.common.navigation.ReturnableRouter
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.ChainNetworkManagementPayload
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.CustomNodePayload
interface SettingsRouter : ReturnableRouter {
fun openWallets()
fun openNetworks()
fun openNetworkDetails(payload: ChainNetworkManagementPayload)
fun openCustomNode(payload: CustomNodePayload)
fun openPushNotificationSettings()
fun openCurrencies()
fun openLanguages()
fun openAppearance()
fun openChangePinCode()
fun openWalletDetails(metaId: Long)
fun openSwitchWallet()
fun openWalletConnectScan()
fun openWalletConnectSessions()
fun openCloudBackupSettings()
fun openManualBackup()
fun addNetwork()
fun openCreateNetworkFlow()
fun openCreateNetworkFlow(payload: AddNetworkPayload.Mode.Add)
fun finishCreateNetworkFlow()
fun openEditNetwork(payload: AddNetworkPayload.Mode.Edit)
fun returnToWallet()
}
@@ -0,0 +1,80 @@
package io.novafoundation.nova.feature_settings_impl.data
import io.novafoundation.nova.common.data.network.runtime.calls.GetBlockHashRequest
import io.novafoundation.nova.common.utils.removeHexPrefix
import io.novafoundation.nova.core.ethereum.Web3Api
import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory
import io.novafoundation.nova.runtime.ethereum.sendSuspend
import io.novafoundation.nova.runtime.ext.evmChainIdFrom
import io.novafoundation.nova.runtime.multiNetwork.chain.model.NetworkType
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnection
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory
import io.novasama.substrate_sdk_android.wsrpc.executeAsync
import io.novasama.substrate_sdk_android.wsrpc.mappers.nonNull
import io.novasama.substrate_sdk_android.wsrpc.mappers.pojo
import java.math.BigInteger
import kotlinx.coroutines.CoroutineScope
interface NodeChainIdRepository {
suspend fun requestChainId(): String
}
class NodeChainIdRepositoryFactory(
private val nodeConnectionFactory: NodeConnectionFactory,
private val web3ApiFactory: Web3ApiFactory
) {
fun create(networkType: NetworkType, nodeUrl: String, coroutineScope: CoroutineScope): NodeChainIdRepository {
val nodeConnection = nodeConnectionFactory.createNodeConnection(nodeUrl, coroutineScope)
return create(networkType, nodeConnection)
}
fun create(networkType: NetworkType, nodeConnection: NodeConnection): NodeChainIdRepository {
return when (networkType) {
NetworkType.SUBSTRATE -> substrate(nodeConnection)
NetworkType.EVM -> evm(nodeConnection)
}
}
fun substrate(nodeConnection: NodeConnection): SubstrateNodeChainIdRepository {
return SubstrateNodeChainIdRepository(nodeConnection)
}
fun evm(nodeConnection: NodeConnection): EthereumNodeChainIdRepository {
return EthereumNodeChainIdRepository(nodeConnection, web3ApiFactory)
}
}
class SubstrateNodeChainIdRepository(
private val nodeConnection: NodeConnection
) : NodeChainIdRepository {
override suspend fun requestChainId(): String {
val genesisHash = nodeConnection.getSocketService().executeAsync(
GetBlockHashRequest(BigInteger.ZERO),
mapper = pojo<String>().nonNull()
)
return genesisHash.removeHexPrefix()
}
}
class EthereumNodeChainIdRepository(
private val nodeConnection: NodeConnection,
private val web3ApiFactory: Web3ApiFactory
) : NodeChainIdRepository {
private val web3Api = createWeb3Api()
override suspend fun requestChainId(): String {
val chainId = web3Api.ethChainId().sendSuspend().chainId
return evmChainIdFrom(chainId)
}
private fun createWeb3Api(): Web3Api {
return web3ApiFactory.createWss(nodeConnection.getSocketService())
}
}
@@ -0,0 +1,91 @@
package io.novafoundation.nova.feature_settings_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.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordCommunicator
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordCommunicator
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator
import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi
import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi
import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi
import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi
import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.presentation.assetIcons.di.AppearanceComponent
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main.di.NetworkManagementListComponent
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.di.AddedNetworkListComponent
import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings.di.CloudBackupSettingsComponent
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.di.AddNetworkMainComponent
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails.di.AddNetworkComponent
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.di.ChainNetworkManagementComponent
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks.di.ExistingNetworkListComponent
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks.di.PreConfiguredNetworksComponent
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.di.CustomNodeComponent
import io.novafoundation.nova.feature_settings_impl.presentation.settings.di.SettingsComponent
import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi
import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
@Component(
dependencies = [
SettingsFeatureDependencies::class,
],
modules = [
SettingsFeatureModule::class,
]
)
@FeatureScope
interface SettingsFeatureComponent : SettingsFeatureApi {
fun settingsComponentFactory(): SettingsComponent.Factory
fun chainNetworkManagementFactory(): ChainNetworkManagementComponent.Factory
fun customNodeFactory(): CustomNodeComponent.Factory
fun networkManagementListFactory(): NetworkManagementListComponent.Factory
fun addNetworkMainFactory(): AddNetworkMainComponent.Factory
fun appearanceFactory(): AppearanceComponent.Factory
fun addNetworkFactory(): AddNetworkComponent.Factory
fun addedNetworkListFactory(): AddedNetworkListComponent.Factory
fun existingNetworkListFactory(): ExistingNetworkListComponent.Factory
fun preConfiguredNetworks(): PreConfiguredNetworksComponent.Factory
fun backupSettings(): CloudBackupSettingsComponent.Factory
@Component.Factory
interface Factory {
fun create(
@BindsInstance router: SettingsRouter,
@BindsInstance syncWalletsBackupPasswordCommunicator: SyncWalletsBackupPasswordCommunicator,
@BindsInstance changeBackupPasswordCommunicator: ChangeBackupPasswordCommunicator,
@BindsInstance restoreBackupPasswordCommunicator: RestoreBackupPasswordCommunicator,
deps: SettingsFeatureDependencies
): SettingsFeatureComponent
}
@Component(
dependencies = [
CommonApi::class,
RuntimeApi::class,
AssetsFeatureApi::class,
CurrencyFeatureApi::class,
AccountFeatureApi::class,
WalletConnectFeatureApi::class,
VersionsFeatureApi::class,
PushNotificationsFeatureApi::class,
CloudBackupFeatureApi::class
]
)
interface SettingsFeatureDependenciesComponent : SettingsFeatureDependencies
}
@@ -0,0 +1,131 @@
package io.novafoundation.nova.feature_settings_impl.di
import android.content.Context
import coil.ImageLoader
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.data.network.AppLinksProvider
import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser
import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository
import io.novafoundation.nova.common.data.repository.BannerVisibilityRepository
import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer
import io.novafoundation.nova.common.resources.AppVersionProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.sequrity.SafeModeService
import io.novafoundation.nova.common.sequrity.TwoFactorVerificationService
import io.novafoundation.nova.common.sequrity.biometry.BiometricServiceFactory
import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory
import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin
import io.novafoundation.nova.feature_account_api.data.cloudBackup.LocalAccountsCloudBackupFacade
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor
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.common.listing.MetaAccountTypePresentationMapper
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin
import io.novafoundation.nova.feature_account_api.presenatation.language.LanguageUseCase
import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper
import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService
import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor
import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor
import io.novafoundation.nova.feature_push_notifications.domain.interactor.WelcomePushNotificationsInteractor
import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase
import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory
import io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState.NodeHealthStateTesterFactory
import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeProviderPool
import io.novafoundation.nova.runtime.repository.ChainNodeRepository
import io.novafoundation.nova.runtime.repository.ChainRepository
import io.novafoundation.nova.runtime.repository.PreConfiguredChainsRepository
import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.CoinGeckoLinkValidationFactory
interface SettingsFeatureDependencies {
val maskingModeUseCase: MaskingModeUseCase
val cloudBackupService: CloudBackupService
val cloudBackupFacade: LocalAccountsCloudBackupFacade
val bannerVisRepository: BannerVisibilityRepository
val runtimeProviderPool: RuntimeProviderPool
val nodeHealthStateTesterFactory: NodeHealthStateTesterFactory
val chainNodeRepository: ChainNodeRepository
val nodeConnectionFactory: NodeConnectionFactory
val web3ApiFactory: Web3ApiFactory
val validationExecutor: ValidationExecutor
val preConfiguredChainsRepository: PreConfiguredChainsRepository
val coinGeckoLinkParser: CoinGeckoLinkParser
val chainRepository: ChainRepository
val coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory
val assetsIconModeRepository: AssetsIconModeRepository
val accountRepository: AccountRepository
val accountInteractor: AccountInteractor
val chainRegistry: ChainRegistry
val languageUseCase: LanguageUseCase
val appLinksProvider: AppLinksProvider
val resourceManager: ResourceManager
val appVersionProvider: AppVersionProvider
val selectedAccountUseCase: SelectedAccountUseCase
val currencyInteractor: CurrencyInteractor
val safeModeService: SafeModeService
val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory
val walletConnectSessionsUseCase: WalletConnectSessionsUseCase
val pushNotificationsInteractor: PushNotificationsInteractor
val welcomePushNotificationsInteractor: WelcomePushNotificationsInteractor
val imageLoader: ImageLoader
val metaAccountTypePresentationMapper: MetaAccountTypePresentationMapper
val addressIconGenerator: AddressIconGenerator
val walletUiUseCase: WalletUiUseCase
val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper
val addressActionsMixinFactory: AddressActionsMixin.Factory
fun biometricServiceFactory(): BiometricServiceFactory
fun twoFactorVerificationService(): TwoFactorVerificationService
fun provideListSelectorMixinFactory(): ListSelectorMixin.Factory
fun actionBottomSheetLauncherFactory(): ActionBottomSheetLauncherFactory
fun progressDialogMixinFactory(): ProgressDialogMixinFactory
fun customDialogProvider(): CustomDialogDisplayer.Presentation
fun context(): Context
}
@@ -0,0 +1,52 @@
package io.novafoundation.nova.feature_settings_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.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordCommunicator
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordCommunicator
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator
import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi
import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi
import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi
import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi
import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
import javax.inject.Inject
@ApplicationScope
class SettingsFeatureHolder @Inject constructor(
featureContainer: FeatureContainer,
private val syncWalletsBackupPasswordCommunicator: SyncWalletsBackupPasswordCommunicator,
private val changeBackupPasswordCommunicator: ChangeBackupPasswordCommunicator,
private val restoreBackupPasswordCommunicator: RestoreBackupPasswordCommunicator,
private val router: SettingsRouter,
) : FeatureApiHolder(featureContainer) {
override fun initializeDependencies(): Any {
val accountFeatureDependencies = DaggerSettingsFeatureComponent_SettingsFeatureDependenciesComponent.builder()
.commonApi(commonApi())
.runtimeApi(getFeature(RuntimeApi::class.java))
.assetsFeatureApi(getFeature(AssetsFeatureApi::class.java))
.currencyFeatureApi(getFeature(CurrencyFeatureApi::class.java))
.versionsFeatureApi(getFeature(VersionsFeatureApi::class.java))
.accountFeatureApi(getFeature(AccountFeatureApi::class.java))
.walletConnectFeatureApi(getFeature(WalletConnectFeatureApi::class.java))
.pushNotificationsFeatureApi(getFeature(PushNotificationsFeatureApi::class.java))
.cloudBackupFeatureApi(getFeature(CloudBackupFeatureApi::class.java))
.build()
return DaggerSettingsFeatureComponent.factory()
.create(
router = router,
syncWalletsBackupPasswordCommunicator = syncWalletsBackupPasswordCommunicator,
changeBackupPasswordCommunicator = changeBackupPasswordCommunicator,
restoreBackupPasswordCommunicator = restoreBackupPasswordCommunicator,
deps = accountFeatureDependencies
)
}
}
@@ -0,0 +1,181 @@
package io.novafoundation.nova.feature_settings_impl.di
import dagger.Module
import io.novafoundation.nova.feature_account_api.data.cloudBackup.LocalAccountsCloudBackupFacade
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService
import io.novafoundation.nova.feature_settings_impl.domain.CloudBackupSettingsInteractor
import io.novafoundation.nova.feature_settings_impl.domain.RealCloudBackupSettingsInteractor
import dagger.Provides
import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser
import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository
import io.novafoundation.nova.common.data.repository.BannerVisibilityRepository
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor
import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.CoinGeckoLinkValidationFactory
import io.novafoundation.nova.feature_settings_impl.data.NodeChainIdRepositoryFactory
import io.novafoundation.nova.feature_settings_impl.domain.AddNetworkInteractor
import io.novafoundation.nova.feature_settings_impl.domain.AppearanceInteractor
import io.novafoundation.nova.feature_settings_impl.domain.CustomNodeInteractor
import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementChainInteractor
import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementInteractor
import io.novafoundation.nova.feature_settings_impl.domain.PreConfiguredNetworksInteractor
import io.novafoundation.nova.feature_settings_impl.domain.RealAddNetworkInteractor
import io.novafoundation.nova.feature_settings_impl.domain.RealAppearanceInteractor
import io.novafoundation.nova.feature_settings_impl.domain.RealCustomNodeInteractor
import io.novafoundation.nova.feature_settings_impl.domain.RealNetworkManagementChainInteractor
import io.novafoundation.nova.feature_settings_impl.domain.RealNetworkManagementInteractor
import io.novafoundation.nova.feature_settings_impl.domain.RealPreConfiguredNetworksInteractor
import io.novafoundation.nova.feature_settings_impl.domain.utils.CustomChainFactory
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListAdapterItemFactory
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.RealNetworkListAdapterItemFactory
import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory
import io.novafoundation.nova.runtime.explorer.BlockExplorerLinkFormatter
import io.novafoundation.nova.runtime.explorer.CommonBlockExplorerLinkFormatter
import io.novafoundation.nova.runtime.explorer.EtherscanBlockExplorerLinkFormatter
import io.novafoundation.nova.runtime.explorer.StatescanBlockExplorerLinkFormatter
import io.novafoundation.nova.runtime.explorer.SubscanBlockExplorerLinkFormatter
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory
import io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState.NodeHealthStateTesterFactory
import io.novafoundation.nova.runtime.repository.ChainNodeRepository
import io.novafoundation.nova.runtime.repository.ChainRepository
import io.novafoundation.nova.runtime.repository.PreConfiguredChainsRepository
@Module
class SettingsFeatureModule {
@Provides
@FeatureScope
fun provideCloudBackupSettingsInteractor(
accountRepository: AccountRepository,
cloudBackupService: CloudBackupService,
cloudBackupFacade: LocalAccountsCloudBackupFacade
): CloudBackupSettingsInteractor {
return RealCloudBackupSettingsInteractor(
accountRepository,
cloudBackupService,
cloudBackupFacade
)
}
@Provides
@FeatureScope
fun provideNetworkManagementInteractor(
chainRegistry: ChainRegistry,
bannerVisRepository: BannerVisibilityRepository
): NetworkManagementInteractor {
return RealNetworkManagementInteractor(chainRegistry, bannerVisRepository)
}
@Provides
@FeatureScope
fun provideNetworkManagementChainInteractor(
chainRegistry: ChainRegistry,
nodeHealthStateTesterFactory: NodeHealthStateTesterFactory,
chainRepository: ChainRepository,
accountInteractor: AccountInteractor
): NetworkManagementChainInteractor {
return RealNetworkManagementChainInteractor(chainRegistry, nodeHealthStateTesterFactory, chainRepository, accountInteractor)
}
@Provides
@FeatureScope
fun provideNetworkListAdapterItemFactory(
resourceManager: ResourceManager
): NetworkListAdapterItemFactory {
return RealNetworkListAdapterItemFactory(resourceManager)
}
@Provides
@FeatureScope
fun provideNodeChainIdRepositoryFactory(
nodeConnectionFactory: NodeConnectionFactory,
web3ApiFactory: Web3ApiFactory
): NodeChainIdRepositoryFactory {
return NodeChainIdRepositoryFactory(nodeConnectionFactory, web3ApiFactory)
}
@Provides
@FeatureScope
fun provideCustomNodeInteractor(
chainRegistry: ChainRegistry,
chainNodeRepository: ChainNodeRepository,
nodeChainIdRepositoryFactory: NodeChainIdRepositoryFactory,
nodeConnectionFactory: NodeConnectionFactory
): CustomNodeInteractor {
return RealCustomNodeInteractor(
chainRegistry,
chainNodeRepository,
nodeChainIdRepositoryFactory,
nodeConnectionFactory
)
}
@Provides
@FeatureScope
fun providePreConfiguredNetworksInteractor(
preConfiguredChainsRepository: PreConfiguredChainsRepository
): PreConfiguredNetworksInteractor {
return RealPreConfiguredNetworksInteractor(
preConfiguredChainsRepository
)
}
@Provides
@FeatureScope
fun provideBlockExplorerLinkFormatter(): BlockExplorerLinkFormatter {
return CommonBlockExplorerLinkFormatter(
listOf(
SubscanBlockExplorerLinkFormatter(),
StatescanBlockExplorerLinkFormatter(),
EtherscanBlockExplorerLinkFormatter()
)
)
}
@Provides
@FeatureScope
fun provideCustomChainFactory(
nodeConnectionFactory: NodeConnectionFactory,
coinGeckoLinkParser: CoinGeckoLinkParser,
blockExplorerLinkFormatter: BlockExplorerLinkFormatter,
nodeChainIdRepositoryFactory: NodeChainIdRepositoryFactory
): CustomChainFactory {
return CustomChainFactory(
nodeConnectionFactory,
nodeChainIdRepositoryFactory,
coinGeckoLinkParser,
blockExplorerLinkFormatter
)
}
@Provides
@FeatureScope
fun provideAddNetworkInteractor(
chainRepository: ChainRepository,
chainRegistry: ChainRegistry,
nodeChainIdRepositoryFactory: NodeChainIdRepositoryFactory,
coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory,
coinGeckoLinkParser: CoinGeckoLinkParser,
nodeConnectionFactory: NodeConnectionFactory,
customChainFactory: CustomChainFactory
): AddNetworkInteractor {
return RealAddNetworkInteractor(
chainRepository,
chainRegistry,
nodeChainIdRepositoryFactory,
coinGeckoLinkValidationFactory,
coinGeckoLinkParser,
nodeConnectionFactory,
customChainFactory
)
}
@Provides
@FeatureScope
fun provideAppearanceInteractor(assetsIconModeRepository: AssetsIconModeRepository): AppearanceInteractor {
return RealAppearanceInteractor(assetsIconModeRepository)
}
}
@@ -0,0 +1,137 @@
package io.novafoundation.nova.feature_settings_impl.domain
import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser
import io.novafoundation.nova.common.data.network.runtime.model.firstTokenSymbol
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.CoinGeckoLinkValidationFactory
import io.novafoundation.nova.feature_settings_impl.data.NodeChainIdRepositoryFactory
import io.novafoundation.nova.feature_settings_impl.domain.model.CustomNetworkPayload
import io.novafoundation.nova.feature_settings_impl.domain.utils.CustomChainFactory
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.CustomNetworkValidationSystem
import io.novafoundation.nova.feature_settings_impl.domain.validation.NodeChainIdSingletonHelper
import io.novafoundation.nova.feature_settings_impl.domain.validation.NodeConnectionSingletonHelper
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.validateTokenSymbol
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.validCoinGeckoLink
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.validateNetworkNodeIsAlive
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.validateNetworkNotAdded
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.validateNodeSupportedByNetwork
import io.novafoundation.nova.runtime.ext.evmChainIdFrom
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.NetworkType
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory
import io.novafoundation.nova.runtime.network.rpc.systemProperties
import io.novafoundation.nova.runtime.repository.ChainRepository
import kotlinx.coroutines.CoroutineScope
interface AddNetworkInteractor {
suspend fun createSubstrateNetwork(
payload: CustomNetworkPayload,
prefilledChain: Chain?,
coroutineScope: CoroutineScope
): Result<Unit>
suspend fun createEvmNetwork(
payload: CustomNetworkPayload,
prefilledChain: Chain?
): Result<Unit>
suspend fun updateChain(
chainId: String,
chainName: String,
tokenSymbol: String,
blockExplorerModel: CustomNetworkPayload.BlockExplorer?,
coingeckoLinkUrl: String?
): Result<Unit>
fun getSubstrateValidationSystem(coroutineScope: CoroutineScope): CustomNetworkValidationSystem
fun getEvmValidationSystem(coroutineScope: CoroutineScope): CustomNetworkValidationSystem
}
class RealAddNetworkInteractor(
private val chainRepository: ChainRepository,
private val chainRegistry: ChainRegistry,
private val nodeChainIdRepositoryFactory: NodeChainIdRepositoryFactory,
private val coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory,
private val coinGeckoLinkParser: CoinGeckoLinkParser,
private val nodeConnectionFactory: NodeConnectionFactory,
private val customChainFactory: CustomChainFactory
) : AddNetworkInteractor {
override suspend fun createSubstrateNetwork(
payload: CustomNetworkPayload,
prefilledChain: Chain?,
coroutineScope: CoroutineScope
) = runCatching {
val chain = customChainFactory.createSubstrateChain(payload, prefilledChain, coroutineScope)
chainRepository.addChain(chain)
}
override suspend fun createEvmNetwork(
payload: CustomNetworkPayload,
prefilledChain: Chain?
) = runCatching {
val chain = customChainFactory.createEvmChain(payload, prefilledChain)
chainRepository.addChain(chain)
}
override fun getSubstrateValidationSystem(coroutineScope: CoroutineScope): CustomNetworkValidationSystem {
return ValidationSystem {
validCoinGeckoLink(coinGeckoLinkValidationFactory)
// Using singleton her to receive chain id only one time for all vaildations
val nodeConnectionHelper = getNodeConnectionSingletonHelper(coroutineScope)
val chainIdHelper = getChainIdSingletonHelper(nodeConnectionHelper)
validateNetworkNodeIsAlive { chainIdHelper.getChainId(NetworkType.SUBSTRATE, it.nodeUrl) }
validateNetworkNotAdded(chainRegistry) { chainIdHelper.getChainId() }
validateTokenSymbol {
val systemProperties = nodeConnectionHelper.getNodeConnection()
.getSocketService()
.systemProperties()
systemProperties.firstTokenSymbol()
}
}
}
override fun getEvmValidationSystem(coroutineScope: CoroutineScope): CustomNetworkValidationSystem {
return ValidationSystem {
validCoinGeckoLink(coinGeckoLinkValidationFactory)
validateNetworkNotAdded(chainRegistry) { evmChainIdFrom(it.evmChainId!!) }
// Using singleton here to receive chain id only one time for all vaildations
val nodeConnectionHelper = getNodeConnectionSingletonHelper(coroutineScope)
val chainIdHelper = getChainIdSingletonHelper(nodeConnectionHelper)
validateNetworkNodeIsAlive { chainIdHelper.getChainId(NetworkType.EVM, it.nodeUrl) }
validateNodeSupportedByNetwork { chainIdHelper.getChainId() }
}
}
override suspend fun updateChain(
chainId: String,
chainName: String,
tokenSymbol: String,
blockExplorerModel: CustomNetworkPayload.BlockExplorer?,
coingeckoLinkUrl: String?
): Result<Unit> {
return runCatching {
val blockExplorer = customChainFactory.getChainExplorer(blockExplorer = blockExplorerModel, chainId = chainId)
val priceId = coingeckoLinkUrl?.let { coinGeckoLinkParser.parse(it).getOrNull()?.priceId }
chainRepository.editChain(chainId, chainName, tokenSymbol, blockExplorer, priceId)
}
}
private fun getNodeConnectionSingletonHelper(coroutineScope: CoroutineScope): NodeConnectionSingletonHelper {
return NodeConnectionSingletonHelper(nodeConnectionFactory, coroutineScope)
}
private fun getChainIdSingletonHelper(helper: NodeConnectionSingletonHelper): NodeChainIdSingletonHelper {
return NodeChainIdSingletonHelper(helper, nodeChainIdRepositoryFactory)
}
}
@@ -0,0 +1,23 @@
package io.novafoundation.nova.feature_settings_impl.domain
import io.novafoundation.nova.common.data.model.AssetIconMode
import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository
import kotlinx.coroutines.flow.Flow
interface AppearanceInteractor {
fun assetIconModeFlow(): Flow<AssetIconMode>
fun setIconMode(iconMode: AssetIconMode)
}
class RealAppearanceInteractor(
private val assetsIconModeRepository: AssetsIconModeRepository
) : AppearanceInteractor {
override fun assetIconModeFlow() = assetsIconModeRepository.assetsIconModeFlow()
override fun setIconMode(iconMode: AssetIconMode) {
assetsIconModeRepository.setAssetsIconMode(iconMode)
}
}
@@ -0,0 +1,141 @@
package io.novafoundation.nova.feature_settings_impl.domain
import io.novafoundation.nova.common.list.GroupedList
import io.novafoundation.nova.common.utils.finally
import io.novafoundation.nova.common.utils.flatMap
import io.novafoundation.nova.feature_account_api.data.cloudBackup.LocalAccountsCloudBackupFacade
import io.novafoundation.nova.feature_account_api.data.cloudBackup.applyNonDestructiveCloudVersionOrThrow
import io.novafoundation.nova.feature_account_api.data.cloudBackup.toMetaAccountType
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.metaAccountTypeComparator
import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService
import io.novafoundation.nova.feature_cloud_backup_api.domain.fetchAndDecryptExistingBackupWithSavedPassword
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.WriteBackupRequest
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.CloudBackupDiff
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.isNotEmpty
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.BackupDiffStrategy
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.BackupDiffStrategyFactory
import io.novafoundation.nova.feature_cloud_backup_api.domain.setLastSyncedTimeAsNow
import io.novafoundation.nova.feature_settings_impl.domain.model.CloudBackupChangedAccount
import io.novafoundation.nova.feature_settings_impl.domain.model.CloudBackupChangedAccount.ChangingType
import kotlinx.coroutines.flow.Flow
import java.util.Date
interface CloudBackupSettingsInteractor {
suspend fun isSyncCloudBackupEnabled(): Boolean
fun observeLastSyncedTime(): Flow<Date?>
suspend fun syncCloudBackup(): Result<Unit>
suspend fun setCloudBackupSyncEnabled(enable: Boolean)
suspend fun deleteCloudBackup(): Result<Unit>
suspend fun writeLocalBackupToCloud(): Result<Unit>
suspend fun signInToCloud(): Result<Unit>
fun prepareSortedLocalChangesFromDiff(cloudBackupDiff: CloudBackupDiff): GroupedList<LightMetaAccount.Type, CloudBackupChangedAccount>
suspend fun applyBackupAccountDiff(cloudBackupDiff: CloudBackupDiff, cloudBackup: CloudBackup): Result<Unit>
}
class RealCloudBackupSettingsInteractor(
private val accountRepository: AccountRepository,
private val cloudBackupService: CloudBackupService,
private val cloudBackupFacade: LocalAccountsCloudBackupFacade,
) : CloudBackupSettingsInteractor {
override fun observeLastSyncedTime(): Flow<Date?> {
return cloudBackupService.session.lastSyncedTimeFlow()
}
override suspend fun syncCloudBackup(): Result<Unit> {
return cloudBackupService.fetchAndDecryptExistingBackupWithSavedPassword()
.mapCatching { cloudBackup ->
cloudBackupFacade.applyNonDestructiveCloudVersionOrThrow(cloudBackup, getCloudBackupDiffStrategy())
}.flatMap {
if (it.cloudChanges.isNotEmpty()) {
writeLocalBackupToCloud()
} else {
Result.success(Unit)
}
}.finally {
cloudBackupService.session.setLastSyncedTimeAsNow()
}.onSuccess {
cloudBackupService.session.setBackupWasInitialized()
}
}
override suspend fun setCloudBackupSyncEnabled(enable: Boolean) {
cloudBackupService.session.setSyncingBackupEnabled(enable)
}
override suspend fun isSyncCloudBackupEnabled(): Boolean {
return cloudBackupService.session.isSyncWithCloudEnabled()
}
override suspend fun deleteCloudBackup(): Result<Unit> {
return cloudBackupService.deleteBackup()
}
override suspend fun writeLocalBackupToCloud(): Result<Unit> {
return cloudBackupService.session.getSavedPassword()
.flatMap { password ->
val localSnapshot = cloudBackupFacade.fullBackupInfoFromLocalSnapshot()
cloudBackupService.writeBackupToCloud(WriteBackupRequest(localSnapshot, password))
}
}
override suspend fun signInToCloud(): Result<Unit> {
return cloudBackupService.signInToCloud()
}
override fun prepareSortedLocalChangesFromDiff(cloudBackupDiff: CloudBackupDiff): GroupedList<LightMetaAccount.Type, CloudBackupChangedAccount> {
val accounts = localAccountChangesFromDiff(cloudBackupDiff.localChanges)
.sortedBy { it.account.name }
return accounts.groupBy { it.account.type.toMetaAccountType() }
.toSortedMap(metaAccountTypeComparator())
}
override suspend fun applyBackupAccountDiff(cloudBackupDiff: CloudBackupDiff, cloudBackup: CloudBackup): Result<Unit> {
return runCatching {
cloudBackupFacade.applyBackupDiff(cloudBackupDiff, cloudBackup)
cloudBackupService.session.setLastSyncedTimeAsNow()
selectMetaAccountIfNeeded()
}.flatMap {
if (cloudBackupDiff.cloudChanges.isNotEmpty()) {
writeLocalBackupToCloud()
} else {
Result.success(Unit)
}
}
}
private fun localAccountChangesFromDiff(diff: CloudBackupDiff.PerSourceDiff): List<CloudBackupChangedAccount> {
return diff.added.map { CloudBackupChangedAccount(ChangingType.ADDED, it) } +
diff.modified.map { CloudBackupChangedAccount(ChangingType.CHANGED, it) } +
diff.removed.map { CloudBackupChangedAccount(ChangingType.REMOVED, it) }
}
private suspend fun selectMetaAccountIfNeeded() {
if (!accountRepository.isAccountSelected()) {
val metaAccounts = accountRepository.getActiveMetaAccounts()
if (metaAccounts.isNotEmpty()) {
accountRepository.selectMetaAccount(metaAccounts.first().id)
}
}
}
private fun getCloudBackupDiffStrategy(): BackupDiffStrategyFactory {
return if (cloudBackupService.session.cloudBackupWasInitialized()) {
BackupDiffStrategy.syncWithCloud()
} else {
BackupDiffStrategy.importFromCloud()
}
}
}
@@ -0,0 +1,66 @@
package io.novafoundation.nova.feature_settings_impl.domain
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.feature_settings_impl.data.NodeChainIdRepositoryFactory
import io.novafoundation.nova.feature_settings_impl.domain.validation.NodeChainIdSingletonHelper
import io.novafoundation.nova.feature_settings_impl.domain.validation.NodeConnectionSingletonHelper
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.NetworkNodeValidationSystem
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.validateNetworkNodeIsAlive
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.validateNodeNotAdded
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.validateNodeSupportedByNetwork
import io.novafoundation.nova.runtime.ext.networkType
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory
import io.novafoundation.nova.runtime.repository.ChainNodeRepository
import kotlinx.coroutines.CoroutineScope
interface CustomNodeInteractor {
suspend fun getNodeDetails(chainId: String, nodeUrl: String): Result<Chain.Node>
suspend fun createNode(chainId: String, url: String, name: String)
suspend fun updateNode(chainId: String, oldUrl: String, url: String, name: String)
fun getValidationSystem(coroutineScope: CoroutineScope, skipNodeExistValidation: Boolean): NetworkNodeValidationSystem
}
class RealCustomNodeInteractor(
private val chainRegistry: ChainRegistry,
private val chainNodeRepository: ChainNodeRepository,
private val nodeChainIdRepositoryFactory: NodeChainIdRepositoryFactory,
private val nodeConnectionFactory: NodeConnectionFactory
) : CustomNodeInteractor {
override suspend fun getNodeDetails(chainId: String, nodeUrl: String): Result<Chain.Node> {
return runCatching {
chainRegistry.getChain(chainId).nodes
.nodes
.first { it.unformattedUrl == nodeUrl }
}
}
override suspend fun createNode(chainId: String, url: String, name: String) {
chainNodeRepository.createChainNode(chainId, url, name)
}
override suspend fun updateNode(chainId: String, oldUrl: String, url: String, name: String) {
chainNodeRepository.saveChainNode(chainId, oldUrl, url, name)
}
override fun getValidationSystem(coroutineScope: CoroutineScope, skipNodeExistValidation: Boolean): NetworkNodeValidationSystem {
return ValidationSystem {
if (!skipNodeExistValidation) {
validateNodeNotAdded()
}
val nodeHelper = NodeConnectionSingletonHelper(nodeConnectionFactory, coroutineScope)
val chainIdRequestSingleton = NodeChainIdSingletonHelper(nodeHelper, nodeChainIdRepositoryFactory)
validateNetworkNodeIsAlive { chainIdRequestSingleton.getChainId(it.chain.networkType(), it.nodeUrl) }
validateNodeSupportedByNetwork { chainIdRequestSingleton.getChainId() }
}
}
}
@@ -0,0 +1,163 @@
package io.novafoundation.nova.feature_settings_impl.domain
import io.novafoundation.nova.common.utils.combine
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor
import io.novafoundation.nova.runtime.ext.Geneses
import io.novafoundation.nova.runtime.ext.genesisHash
import io.novafoundation.nova.runtime.ext.isCustomNetwork
import io.novafoundation.nova.runtime.ext.isDisabled
import io.novafoundation.nova.runtime.ext.isEnabled
import io.novafoundation.nova.runtime.ext.selectedUnformattedWssNodeUrlOrNull
import io.novafoundation.nova.runtime.ext.wssNodes
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Nodes.NodeSelectionStrategy
import io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState.NodeHealthStateTesterFactory
import io.novafoundation.nova.runtime.repository.ChainRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withContext
class ChainNetworkState(
val chain: Chain,
val networkCanBeDisabled: Boolean,
val nodeHealthStates: List<NodeHealthState>,
val connectingNode: Chain.Node?
)
class NodeHealthState(
val node: Chain.Node,
val state: State
) {
sealed interface State {
object Connecting : State
class Connected(val ms: Long) : State
object Disabled : State
}
}
interface NetworkManagementChainInteractor {
fun chainStateFlow(chainId: String, coroutineScope: CoroutineScope): Flow<ChainNetworkState>
suspend fun toggleAutoBalance(chainId: String)
suspend fun selectNode(chainId: String, unformattedNodeUrl: String)
suspend fun toggleChainEnableState(chainId: String)
suspend fun deleteNetwork(chainId: String)
suspend fun deleteNode(chainId: String, unformattedNodeUrl: String)
}
class RealNetworkManagementChainInteractor(
private val chainRegistry: ChainRegistry,
private val nodeHealthStateTesterFactory: NodeHealthStateTesterFactory,
private val chainRepository: ChainRepository,
private val accountInteractor: AccountInteractor
) : NetworkManagementChainInteractor {
override fun chainStateFlow(chainId: String, coroutineScope: CoroutineScope): Flow<ChainNetworkState> {
return chainRegistry.chainsById
.mapNotNull { it[chainId] }
.flatMapLatest { chain ->
combine(activeNodeFlow(chainId), nodesHealthState(chain, coroutineScope)) { activeNode, nodeHealthStates ->
ChainNetworkState(chain, networkCanBeDisabled(chain), nodeHealthStates, activeNode)
}
}
}
override suspend fun toggleAutoBalance(chainId: String) {
val chain = chainRegistry.getChain(chainId)
chainRegistry.setWssNodeSelectionStrategy(chainId, chain.nodes.strategyForToggledWssAutoBalance())
}
private fun Chain.Nodes.strategyForToggledWssAutoBalance(): NodeSelectionStrategy {
return when (wssNodeSelectionStrategy) {
NodeSelectionStrategy.AutoBalance -> {
val firstNode = wssNodes().first()
NodeSelectionStrategy.SelectedNode(firstNode.unformattedUrl)
}
is NodeSelectionStrategy.SelectedNode -> NodeSelectionStrategy.AutoBalance
}
}
override suspend fun selectNode(chainId: String, unformattedNodeUrl: String) {
val strategy = NodeSelectionStrategy.SelectedNode(unformattedNodeUrl)
chainRegistry.setWssNodeSelectionStrategy(chainId, strategy)
}
override suspend fun toggleChainEnableState(chainId: String) {
val chain = chainRegistry.getChain(chainId)
val connectionState = if (chain.isEnabled) Chain.ConnectionState.DISABLED else Chain.ConnectionState.FULL_SYNC
chainRegistry.changeChainConnectionState(chainId, connectionState)
}
override suspend fun deleteNetwork(chainId: String) {
val chain = chainRegistry.getChain(chainId)
require(chain.isCustomNetwork)
withContext(Dispatchers.Default) { accountInteractor.deleteProxiedMetaAccountsByChain(chainId) } // Delete proxied meta accounts manually
chainRepository.deleteNetwork(chainId)
}
override suspend fun deleteNode(chainId: String, unformattedNodeUrl: String) {
val chain = chainRegistry.getChain(chainId)
require(chain.nodes.nodes.size > 1)
chainRepository.deleteNode(chainId, unformattedNodeUrl)
if (chain.selectedUnformattedWssNodeUrlOrNull == unformattedNodeUrl) {
chainRegistry.setWssNodeSelectionStrategy(chainId, NodeSelectionStrategy.AutoBalance)
}
}
private fun networkCanBeDisabled(chain: Chain): Boolean {
return chain.genesisHash != Chain.Geneses.POLKADOT
}
private fun nodesHealthState(chain: Chain, coroutineScope: CoroutineScope): Flow<List<NodeHealthState>> {
return chain.nodes.wssNodes().map {
nodeHealthState(chain, it, coroutineScope)
}.combine()
}
private fun activeNodeFlow(chainId: String): Flow<Chain.Node?> {
val activeConnection = chainRegistry.getConnectionOrNull(chainId)
return activeConnection?.currentUrl?.map { it?.node } ?: flowOf { null }
}
private fun nodeHealthState(chain: Chain, node: Chain.Node, coroutineScope: CoroutineScope): Flow<NodeHealthState> {
return flow {
if (chain.isDisabled) {
emit(NodeHealthState(node, NodeHealthState.State.Disabled))
return@flow
}
emit(NodeHealthState(node, NodeHealthState.State.Connecting))
val nodeConnectionDelay = nodeHealthStateTesterFactory.create(chain, node, coroutineScope)
.testNodeHealthState()
.getOrNull()
nodeConnectionDelay?.let {
emit(NodeHealthState(node, NodeHealthState.State.Connected(it)))
}
}
}
}
@@ -0,0 +1,83 @@
package io.novafoundation.nova.feature_settings_impl.domain
import io.novafoundation.nova.common.data.repository.BannerVisibilityRepository
import io.novafoundation.nova.common.utils.filterList
import io.novafoundation.nova.common.utils.combine
import io.novafoundation.nova.runtime.ext.defaultComparatorFrom
import io.novafoundation.nova.runtime.ext.isCustomNetwork
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.wsrpc.state.SocketStateMachine
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
private const val INTEGRATE_NETWORKS_BANNER_TAG = "INTEGRATE_NETWORKS_BANNER_TAG"
class NetworkState(
val chain: Chain,
val connectionState: SocketStateMachine.State?
)
interface NetworkManagementInteractor {
fun shouldShowBanner(): Flow<Boolean>
suspend fun hideBanner()
fun defaultNetworksFlow(): Flow<List<NetworkState>>
fun addedNetworksFlow(): Flow<List<NetworkState>>
}
class RealNetworkManagementInteractor(
private val chainRegistry: ChainRegistry,
private val bannerVisibilityRepository: BannerVisibilityRepository
) : NetworkManagementInteractor {
override fun shouldShowBanner(): Flow<Boolean> {
return bannerVisibilityRepository.shouldShowBannerFlow(INTEGRATE_NETWORKS_BANNER_TAG)
}
override suspend fun hideBanner() {
bannerVisibilityRepository.hideBanner(INTEGRATE_NETWORKS_BANNER_TAG)
}
override fun defaultNetworksFlow(): Flow<List<NetworkState>> {
return networksFlow { !it.isCustomNetwork }
}
override fun addedNetworksFlow(): Flow<List<NetworkState>> {
return networksFlow { it.isCustomNetwork }
}
private fun networksFlow(filter: (Chain) -> Boolean) = chainRegistry.currentChains
.filterList(filter)
.flatMapLatest { chains ->
connectionsFlow(sortChains(chains))
}
private fun connectionsFlow(chains: List<Chain>): Flow<List<NetworkState>> {
if (chains.isEmpty()) {
return flowOf(emptyList())
}
return chains.map { chain ->
val connectionFlow = chainRegistry.getConnectionOrNull(chain.id)?.state ?: flowOf<SocketStateMachine.State?>(SocketStateMachine.State.Disconnected)
connectionFlow
.distinctUntilChanged { old, new -> old?.isConnected() == new?.isConnected() }
.map { state -> NetworkState(chain, state) }
}.combine()
}
private fun sortChains(chains: List<Chain>): List<Chain> {
return chains.sortedWith(Chain.defaultComparatorFrom { it })
}
// It's enough for us to have 2 states for this implementation: connected and not connected
private fun SocketStateMachine.State.isConnected(): Boolean {
return this is SocketStateMachine.State.Connected
}
}
@@ -0,0 +1,44 @@
package io.novafoundation.nova.feature_settings_impl.domain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.LightChain
import io.novafoundation.nova.runtime.repository.PreConfiguredChainsRepository
interface PreConfiguredNetworksInteractor {
suspend fun getPreConfiguredNetworks(): Result<List<LightChain>>
suspend fun excludeChains(list: List<LightChain>, chainIds: Set<String>): List<LightChain>
fun searchNetworks(query: String?, list: List<LightChain>): List<LightChain>
suspend fun getPreConfiguredNetwork(chainId: String): Result<Chain>
}
class RealPreConfiguredNetworksInteractor(
private val preConfiguredChainsRepository: PreConfiguredChainsRepository
) : PreConfiguredNetworksInteractor {
override suspend fun getPreConfiguredNetworks(): Result<List<LightChain>> {
return preConfiguredChainsRepository.getPreConfiguredChains()
.map { lightChains ->
lightChains.sortedBy { it.name }
}
}
override suspend fun excludeChains(list: List<LightChain>, chainIds: Set<String>): List<LightChain> {
return list.filterNot { lightChain -> chainIds.contains(lightChain.id) }
}
override fun searchNetworks(query: String?, list: List<LightChain>): List<LightChain> {
if (query.isNullOrBlank()) return list
val loverCaseQuery = query.trim().lowercase()
return list.filter { lightChain -> lightChain.name.lowercase().startsWith(loverCaseQuery) }
}
override suspend fun getPreConfiguredNetwork(chainId: String): Result<Chain> {
return preConfiguredChainsRepository.getPreconfiguredChainById(chainId)
}
}
@@ -0,0 +1,12 @@
package io.novafoundation.nova.feature_settings_impl.domain.model
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup
class CloudBackupChangedAccount(val changingType: ChangingType, val account: CloudBackup.WalletPublicInfo) {
enum class ChangingType {
ADDED,
REMOVED,
CHANGED
}
}
@@ -0,0 +1,18 @@
package io.novafoundation.nova.feature_settings_impl.domain.model
data class CustomNetworkPayload(
val nodeUrl: String,
val nodeName: String,
val chainName: String,
val tokenSymbol: String,
val evmChainId: Int?,
val blockExplorer: BlockExplorer?,
val coingeckoLinkUrl: String?,
val ignoreChainModifying: Boolean,
) {
class BlockExplorer(
val name: String,
val url: String
)
}
@@ -0,0 +1,202 @@
package io.novafoundation.nova.feature_settings_impl.domain.utils
import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser
import io.novafoundation.nova.common.data.network.runtime.model.SystemProperties
import io.novafoundation.nova.common.data.network.runtime.model.firstTokenDecimals
import io.novafoundation.nova.common.utils.DEFAULT_PREFIX
import io.novafoundation.nova.common.utils.Precision
import io.novafoundation.nova.common.utils.asPrecision
import io.novafoundation.nova.common.utils.asTokenSymbol
import io.novafoundation.nova.common.utils.orFalse
import io.novafoundation.nova.feature_settings_impl.data.NodeChainIdRepositoryFactory
import io.novafoundation.nova.feature_settings_impl.domain.model.CustomNetworkPayload
import io.novafoundation.nova.runtime.explorer.BlockExplorerLinkFormatter
import io.novafoundation.nova.runtime.ext.EVM_DEFAULT_TOKEN_DECIMALS
import io.novafoundation.nova.runtime.ext.evmChainIdFrom
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Nodes.NodeSelectionStrategy
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnection
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory
import io.novafoundation.nova.runtime.network.rpc.systemProperties
import io.novafoundation.nova.runtime.util.fetchRuntimeSnapshot
import io.novafoundation.nova.runtime.util.isEthereumAddress
import io.novasama.substrate_sdk_android.ss58.SS58Encoder
import kotlinx.coroutines.CoroutineScope
class CustomChainFactory(
private val nodeConnectionFactory: NodeConnectionFactory,
private val nodeChainIdRepositoryFactory: NodeChainIdRepositoryFactory,
private val coinGeckoLinkParser: CoinGeckoLinkParser,
private val blockExplorerLinkFormatter: BlockExplorerLinkFormatter,
) {
suspend fun createSubstrateChain(
payload: CustomNetworkPayload,
prefilledChain: Chain?,
coroutineScope: CoroutineScope
): Chain {
val nodeConnection = nodeConnectionFactory.createNodeConnection(payload.nodeUrl, coroutineScope)
val substrateNodeIdRequester = nodeChainIdRepositoryFactory.substrate(nodeConnection)
val runtime = nodeConnection.getSocketService().fetchRuntimeSnapshot()
val (precision, addressPrefix) = getMainTokenPrecisionAndAddressPrefix(prefilledChain, nodeConnection)
return createChain(
chainId = substrateNodeIdRequester.requestChainId(),
addressPrefix = addressPrefix,
isEthereumBased = runtime.isEthereumAddress(),
hasSubstrateRuntime = true,
assetDecimals = precision,
assetType = prefilledChain?.utilityAsset?.type ?: Chain.Asset.Type.Native,
payload = payload,
prefilledChain = prefilledChain,
)
}
fun createEvmChain(
payload: CustomNetworkPayload,
prefilledChain: Chain?
): Chain {
val evmChainId = payload.evmChainId!!
val chainId = evmChainIdFrom(evmChainId)
return createChain(
chainId = chainId,
addressPrefix = evmChainId,
isEthereumBased = true,
hasSubstrateRuntime = false,
assetDecimals = EVM_DEFAULT_TOKEN_DECIMALS.asPrecision(),
assetType = Chain.Asset.Type.EvmNative,
payload = payload,
prefilledChain = prefilledChain,
)
}
private fun createChain(
chainId: String,
addressPrefix: Int,
isEthereumBased: Boolean,
hasSubstrateRuntime: Boolean,
assetDecimals: Precision,
assetType: Chain.Asset.Type,
payload: CustomNetworkPayload,
prefilledChain: Chain?
): Chain {
val priceId = payload.coingeckoLinkUrl?.let { coinGeckoLinkParser.parse(it).getOrNull()?.priceId }
val prefilledUtilityAsset = prefilledChain?.utilityAsset
val asset = Chain.Asset(
id = 0,
name = payload.chainName,
enabled = true,
icon = prefilledUtilityAsset?.icon,
priceId = priceId,
chainId = chainId,
symbol = payload.tokenSymbol.asTokenSymbol(),
precision = assetDecimals,
buyProviders = prefilledUtilityAsset?.buyProviders.orEmpty(),
sellProviders = prefilledUtilityAsset?.sellProviders.orEmpty(),
staking = prefilledUtilityAsset?.staking.orEmpty(),
type = assetType,
source = Chain.Asset.Source.MANUAL,
)
val explorer = getChainExplorer(payload.blockExplorer, chainId)
val nodes = Chain.Nodes(
autoBalanceStrategy = prefilledChain?.nodes?.autoBalanceStrategy ?: Chain.Nodes.AutoBalanceStrategy.ROUND_ROBIN,
wssNodeSelectionStrategy = NodeSelectionStrategy.AutoBalance,
nodes = createNodeList(chainId, prefilledChain, payload)
)
return Chain(
id = chainId,
parentId = prefilledChain?.parentId,
name = payload.chainName,
assets = listOf(asset),
nodes = nodes,
explorers = explorer?.let(::listOf) ?: prefilledChain?.explorers.orEmpty(),
externalApis = prefilledChain?.externalApis.orEmpty(),
icon = prefilledChain?.icon,
addressPrefix = addressPrefix,
legacyAddressPrefix = null,
types = prefilledChain?.types,
isEthereumBased = isEthereumBased,
isTestNet = prefilledChain?.isTestNet.orFalse(),
source = Chain.Source.CUSTOM,
hasSubstrateRuntime = hasSubstrateRuntime,
pushSupport = prefilledChain?.pushSupport.orFalse(),
hasCrowdloans = prefilledChain?.hasCrowdloans.orFalse(),
multisigSupport = prefilledChain?.multisigSupport.orFalse(),
supportProxy = prefilledChain?.supportProxy.orFalse(),
governance = prefilledChain?.governance.orEmpty(),
swap = prefilledChain?.swap.orEmpty(),
customFee = prefilledChain?.customFee.orEmpty(),
connectionState = Chain.ConnectionState.FULL_SYNC,
additional = prefilledChain?.additional
)
}
private fun createNodeList(
chainId: String,
prefilledChain: Chain?,
input: CustomNetworkPayload
): List<Chain.Node> {
val inputNode = Chain.Node(
chainId = chainId,
unformattedUrl = input.nodeUrl,
name = input.nodeName,
orderId = 0,
isCustom = true,
)
val prefilledNodes = prefilledChain?.nodes?.nodes.orEmpty()
val prefilledExceptInput = prefilledNodes.mapNotNull {
val differentFromInput = it.unformattedUrl != inputNode.unformattedUrl
if (differentFromInput) {
// Consider prefilled nodes as custom
it.copy(isCustom = true)
} else {
null
}
}
return buildList {
add(inputNode)
addAll(prefilledExceptInput)
}
}
fun getChainExplorer(blockExplorer: CustomNetworkPayload.BlockExplorer?, chainId: String): Chain.Explorer? {
return blockExplorer?.let {
val links = blockExplorerLinkFormatter.format(it.url)
Chain.Explorer(
chainId = chainId,
name = it.name,
account = links?.account,
extrinsic = links?.extrinsic,
event = links?.event,
)
}
}
private suspend fun getSubstrateChainProperties(nodeConnection: NodeConnection): SystemProperties {
return nodeConnection.getSocketService().systemProperties()
}
private suspend fun getMainTokenPrecisionAndAddressPrefix(chain: Chain?, nodeConnection: NodeConnection): Pair<Precision, Int> {
if (chain != null) {
val asset = chain.utilityAsset
return Pair(asset.precision, chain.addressPrefix)
} else {
val systemProperties = getSubstrateChainProperties(nodeConnection)
return Pair(
systemProperties.firstTokenDecimals().asPrecision(),
systemProperties.ss58Format ?: systemProperties.SS58Prefix ?: SS58Encoder.DEFAULT_PREFIX.toInt()
)
}
}
}
@@ -0,0 +1,32 @@
package io.novafoundation.nova.feature_settings_impl.domain.validation
import io.novafoundation.nova.feature_settings_impl.data.NodeChainIdRepositoryFactory
import io.novafoundation.nova.runtime.multiNetwork.chain.model.NetworkType
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class NodeChainIdSingletonHelper(
private val nodeConnectionSingletonHelper: NodeConnectionSingletonHelper,
private val nodeChainIdRepositoryFactory: NodeChainIdRepositoryFactory
) {
private var chainId: String? = null
private val mutex = Mutex()
suspend fun getChainId(networkType: NetworkType, nodeUrl: String): String {
return mutex.withLock {
if (chainId == null) {
val nodeConnection = nodeConnectionSingletonHelper.getNodeConnection(nodeUrl)
val nodeChainIdRepository = nodeChainIdRepositoryFactory.create(networkType, nodeConnection)
chainId = nodeChainIdRepository.requestChainId()
chainId!!
} else {
chainId!!
}
}
}
fun getChainId(): String {
return chainId!!
}
}
@@ -0,0 +1,31 @@
package io.novafoundation.nova.feature_settings_impl.domain.validation
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnection
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class NodeConnectionSingletonHelper(
private val nodeConnectionFactory: NodeConnectionFactory,
private val coroutineScope: CoroutineScope
) {
private var nodeConnection: NodeConnection? = null
private val mutex = Mutex()
suspend fun getNodeConnection(nodeUrl: String): NodeConnection {
return mutex.withLock {
if (nodeConnection == null) {
nodeConnection = nodeConnectionFactory.createNodeConnection(nodeUrl, coroutineScope)
nodeConnection!!
} else {
nodeConnection!!
}
}
}
fun getNodeConnection(): NodeConnection {
return nodeConnection!!
}
}
@@ -0,0 +1,29 @@
package io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork
import io.novafoundation.nova.common.validation.Validation
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.ValidationSystemBuilder
import io.novafoundation.nova.common.validation.validOrError
class CustomNetworkAssetValidation<P, F>(
private val chainMainAssetSymbolRequester: suspend (P) -> String,
private val symbol: (P) -> String,
private val failure: (P, String) -> F
) : Validation<P, F> {
override suspend fun validate(value: P): ValidationStatus<F> {
val networkSymbol = chainMainAssetSymbolRequester(value)
return validOrError(networkSymbol == symbol(value)) {
failure(value, networkSymbol)
}
}
}
fun <P, F> ValidationSystemBuilder<P, F>.validateAssetIsMain(
chainMainAssetSymbolRequester: suspend (P) -> String,
symbol: (P) -> String,
failure: (P, String) -> F
) = validate(
CustomNetworkAssetValidation(chainMainAssetSymbolRequester, symbol, failure)
)
@@ -0,0 +1,73 @@
package io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.common.validation.ValidationSystemBuilder
import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.CoinGeckoLinkValidationFactory
import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.validCoinGeckoLink
import io.novafoundation.nova.feature_settings_impl.domain.model.CustomNetworkPayload
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.nodeSupportedByNetworkValidation
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.validateNetworkNodeIsAlive
import io.novafoundation.nova.runtime.ext.evmChainIdFrom
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
typealias CustomNetworkValidationSystem = ValidationSystem<CustomNetworkPayload, CustomNetworkFailure>
typealias CustomNetworkValidationSystemBuilder = ValidationSystemBuilder<CustomNetworkPayload, CustomNetworkFailure>
sealed interface CustomNetworkFailure {
class DefaultNetworkAlreadyAdded(val networkName: String) : CustomNetworkFailure
class CustomNetworkAlreadyAdded(val networkName: String) : CustomNetworkFailure
object WrongNetwork : CustomNetworkFailure
object NodeIsNotAlive : CustomNetworkFailure
object CoingeckoLinkBadFormat : CustomNetworkFailure
class WrongAsset(val usedSymbol: String, val correctSymbol: String) : CustomNetworkFailure
}
fun CustomNetworkValidationSystemBuilder.validateNetworkNodeIsAlive(
nodeHealthStateCheckRequest: suspend (CustomNetworkPayload) -> Unit
) = validateNetworkNodeIsAlive(
nodeHealthStateCheckRequest,
nodeUrl = { it.nodeUrl },
failure = { CustomNetworkFailure.NodeIsNotAlive }
)
fun CustomNetworkValidationSystemBuilder.validateNodeSupportedByNetwork(
nodeChainIdRequester: suspend (CustomNetworkPayload) -> String
) = nodeSupportedByNetworkValidation(
nodeChainIdRequester = { nodeChainIdRequester(it) },
originalChainId = { it.evmChainId?.let { evmChainIdFrom(it) } },
failure = { CustomNetworkFailure.WrongNetwork }
)
fun CustomNetworkValidationSystemBuilder.validateNetworkNotAdded(
chainRegistry: ChainRegistry,
chainIdRequester: suspend (CustomNetworkPayload) -> String
) = validateNetworkNotAdded(
chainRegistry = chainRegistry,
chainIdRequester = { chainIdRequester(it) },
ignoreChainModifying = { it.ignoreChainModifying },
defaultNetworkFailure = { payload, chain -> CustomNetworkFailure.DefaultNetworkAlreadyAdded(chain.name) },
customNetworkFailure = { payload, chain -> CustomNetworkFailure.CustomNetworkAlreadyAdded(chain.name) }
)
fun CustomNetworkValidationSystemBuilder.validCoinGeckoLink(
coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory
) = validCoinGeckoLink(
coinGeckoLinkValidationFactory = coinGeckoLinkValidationFactory,
optional = true,
link = { it.coingeckoLinkUrl },
error = { CustomNetworkFailure.CoingeckoLinkBadFormat }
)
fun CustomNetworkValidationSystemBuilder.validateTokenSymbol(
tokenSymbolRequester: suspend (CustomNetworkPayload) -> String
) = validateAssetIsMain(
chainMainAssetSymbolRequester = tokenSymbolRequester,
symbol = { it.tokenSymbol },
failure = { payload, correctSymbol -> CustomNetworkFailure.WrongAsset(payload.tokenSymbol, correctSymbol) },
)
@@ -0,0 +1,43 @@
package io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork
import io.novafoundation.nova.common.validation.Validation
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.ValidationSystemBuilder
import io.novafoundation.nova.common.validation.valid
import io.novafoundation.nova.common.validation.validationError
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chainsById
class NetworkAlreadyAddedValidation<P, F>(
private val chainRegistry: ChainRegistry,
private val chainIdRequester: suspend (P) -> String,
private val ignoreChainModifying: (P) -> Boolean,
private val defaultNetworkFailure: (P, Chain) -> F,
private val customNetworkWarning: (P, Chain) -> F
) : Validation<P, F> {
override suspend fun validate(value: P): ValidationStatus<F> {
val chainId = chainIdRequester(value)
val chain = chainRegistry.chainsById()[chainId]
if (chain != null && !ignoreChainModifying(value)) {
return when (chain.source) {
Chain.Source.DEFAULT -> validationError(defaultNetworkFailure(value, chain))
Chain.Source.CUSTOM -> validationError(customNetworkWarning(value, chain))
}
}
return valid()
}
}
fun <P, F> ValidationSystemBuilder<P, F>.validateNetworkNotAdded(
chainRegistry: ChainRegistry,
chainIdRequester: suspend (P) -> String,
ignoreChainModifying: (P) -> Boolean,
defaultNetworkFailure: (P, Chain) -> F,
customNetworkFailure: (P, Chain) -> F
) = validate(
NetworkAlreadyAddedValidation(chainRegistry, chainIdRequester, ignoreChainModifying, defaultNetworkFailure, customNetworkFailure)
)
@@ -0,0 +1,38 @@
package io.novafoundation.nova.feature_settings_impl.domain.validation.customNode
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.common.validation.ValidationSystemBuilder
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
typealias NetworkNodeValidationSystem = ValidationSystem<NetworkNodePayload, NetworkNodeFailure>
typealias NetworkNodeValidationSystemBuilder = ValidationSystemBuilder<NetworkNodePayload, NetworkNodeFailure>
class NetworkNodePayload(
val chain: Chain,
val nodeUrl: String
)
sealed interface NetworkNodeFailure {
class NodeAlreadyExists(val node: Chain.Node) : NetworkNodeFailure
class WrongNetwork(val chain: Chain) : NetworkNodeFailure
object NodeIsNotAlive : NetworkNodeFailure
}
fun NetworkNodeValidationSystemBuilder.validateNetworkNodeIsAlive(
nodeHealthStateCheckRequest: suspend (NetworkNodePayload) -> Unit
) = validateNetworkNodeIsAlive(
nodeHealthStateCheckRequest,
nodeUrl = { it.nodeUrl },
failure = { NetworkNodeFailure.NodeIsNotAlive }
)
fun NetworkNodeValidationSystemBuilder.validateNodeSupportedByNetwork(
nodeChainIdRequester: suspend (NetworkNodePayload) -> String
) = nodeSupportedByNetworkValidation(
nodeChainIdRequester = { nodeChainIdRequester(it) },
originalChainId = { it.chain.id },
failure = { NetworkNodeFailure.WrongNetwork(it.chain) }
)
@@ -0,0 +1,35 @@
package io.novafoundation.nova.feature_settings_impl.domain.validation.customNode
import io.novafoundation.nova.common.utils.Urls
import io.novafoundation.nova.common.validation.Validation
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.ValidationSystemBuilder
import io.novafoundation.nova.common.validation.valid
import io.novafoundation.nova.common.validation.validationError
class NodeAlreadyAddedValidation : Validation<NetworkNodePayload, NetworkNodeFailure> {
override suspend fun validate(value: NetworkNodePayload): ValidationStatus<NetworkNodeFailure> {
try {
val node = value.chain.nodes
.nodes
.firstOrNull { it.unformattedUrl == value.nodeUrl.normalize() }
if (node != null) {
return validationError(NetworkNodeFailure.NodeAlreadyExists(node))
}
return valid()
} catch (e: Exception) {
return validationError(NetworkNodeFailure.NodeIsNotAlive)
}
}
private fun String.normalize(): String {
return Urls.normalizePath(this)
}
}
fun ValidationSystemBuilder<NetworkNodePayload, NetworkNodeFailure>.validateNodeNotAdded() = validate(
NodeAlreadyAddedValidation()
)
@@ -0,0 +1,38 @@
package io.novafoundation.nova.feature_settings_impl.domain.validation.customNode
import io.novafoundation.nova.common.validation.Validation
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.ValidationSystemBuilder
import io.novafoundation.nova.common.validation.valid
import io.novafoundation.nova.common.validation.validationError
import java.lang.Exception
import kotlinx.coroutines.withTimeout
import kotlin.time.Duration.Companion.seconds
class NetworkNodeIsAliveValidation<P, F>(
private val nodeHealthStateCheckRequest: suspend (P) -> Unit,
private val nodeUrl: (P) -> String,
private val failure: (P) -> F
) : Validation<P, F> {
override suspend fun validate(value: P): ValidationStatus<F> {
return try {
val url = nodeUrl(value)
require(url.startsWith("wss://") || url.startsWith("ws://"))
withTimeout(10.seconds) { nodeHealthStateCheckRequest(value) }
valid()
} catch (e: Exception) {
validationError(failure(value))
}
}
}
fun <P, F> ValidationSystemBuilder<P, F>.validateNetworkNodeIsAlive(
nodeHealthStateCheckRequest: suspend (P) -> Unit,
nodeUrl: (P) -> String,
failure: (P) -> F
) = validate(
NetworkNodeIsAliveValidation(nodeHealthStateCheckRequest, nodeUrl, failure)
)
@@ -0,0 +1,33 @@
package io.novafoundation.nova.feature_settings_impl.domain.validation.customNode
import io.novafoundation.nova.common.validation.Validation
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.ValidationSystemBuilder
import io.novafoundation.nova.common.validation.validOrError
class NodeSupportedByNetworkValidation<P, F>(
private val nodeChainIdRequester: suspend (P) -> String,
private val originalChainId: (P) -> String?,
private val failure: (P) -> F
) : Validation<P, F> {
override suspend fun validate(value: P): ValidationStatus<F> {
val nodeChainId = nodeChainIdRequester(value)
return validOrError(nodeChainId == originalChainId(value)) {
failure(value)
}
}
}
fun <P, F> ValidationSystemBuilder<P, F>.nodeSupportedByNetworkValidation(
nodeChainIdRequester: suspend (P) -> String,
originalChainId: (P) -> String?,
failure: (P) -> F
) = validate(
NodeSupportedByNetworkValidation(
nodeChainIdRequester,
originalChainId,
failure
)
)
@@ -0,0 +1,42 @@
package io.novafoundation.nova.feature_settings_impl.presentation.assetIcons
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi
import io.novafoundation.nova.feature_settings_impl.databinding.FragmentAppearanceBinding
import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent
class AppearanceFragment : BaseFragment<AppearanceViewModel, FragmentAppearanceBinding>() {
override fun createBinding() = FragmentAppearanceBinding.inflate(layoutInflater)
override fun initViews() {
binder.appearanceToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.appearanceWhiteButton.setOnClickListener { viewModel.selectWhiteIcon() }
binder.appearanceColoredButton.setOnClickListener { viewModel.selectColoredIcon() }
binder.appearanceWhiteButton.background = getRippleDrawable(cornerSizeInDp = 10)
binder.appearanceColoredButton.background = getRippleDrawable(cornerSizeInDp = 10)
}
override fun inject() {
FeatureUtils.getFeature<SettingsFeatureComponent>(
requireContext(),
SettingsFeatureApi::class.java
)
.appearanceFactory()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: AppearanceViewModel) {
viewModel.assetIconsStateFlow.observe {
binder.appearanceWhiteIcon.isSelected = it.whiteActive
binder.appearanceWhiteText.isSelected = it.whiteActive
binder.appearanceColoredIcon.isSelected = it.coloredActive
binder.appearanceColoredText.isSelected = it.coloredActive
}
}
}
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_settings_impl.presentation.assetIcons
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.data.model.AssetIconMode
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.AppearanceInteractor
import kotlinx.coroutines.flow.map
class AssetIconsStateModel(
val whiteActive: Boolean,
val coloredActive: Boolean
)
class AppearanceViewModel(
private val interactor: AppearanceInteractor,
private val router: SettingsRouter
) : BaseViewModel() {
val assetIconsStateFlow = interactor.assetIconModeFlow()
.map {
AssetIconsStateModel(
whiteActive = it == AssetIconMode.WHITE,
coloredActive = it == AssetIconMode.COLORED
)
}
fun selectWhiteIcon() {
interactor.setIconMode(AssetIconMode.WHITE)
router.returnToWallet()
}
fun selectColoredIcon() {
interactor.setIconMode(AssetIconMode.COLORED)
router.returnToWallet()
}
fun backClicked() {
router.back()
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_settings_impl.presentation.assetIcons.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_settings_impl.presentation.assetIcons.AppearanceFragment
@Subcomponent(
modules = [
AppearanceModule::class
]
)
@ScreenScope
interface AppearanceComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment
): AppearanceComponent
}
fun inject(fragment: AppearanceFragment)
}
@@ -0,0 +1,35 @@
package io.novafoundation.nova.feature_settings_impl.presentation.assetIcons.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.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.AppearanceInteractor
import io.novafoundation.nova.feature_settings_impl.presentation.assetIcons.AppearanceViewModel
@Module(includes = [ViewModelModule::class])
class AppearanceModule {
@Provides
@IntoMap
@ViewModelKey(AppearanceViewModel::class)
fun provideViewModel(
interactor: AppearanceInteractor,
router: SettingsRouter
): ViewModel {
return AppearanceViewModel(
interactor,
router
)
}
@Provides
fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): AppearanceViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(AppearanceViewModel::class.java)
}
}
@@ -0,0 +1,46 @@
package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff
import android.content.Context
import android.view.LayoutInflater
import io.novafoundation.nova.common.utils.addColor
import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter
import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.CloudBackupDiff
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.databinding.FragmentBackupDiffBinding
import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff.adapter.CloudBackupDiffAdapter
class CloudBackupDiffBottomSheet(
context: Context,
private val payload: Payload,
onApply: (CloudBackupDiff, CloudBackup) -> Unit,
) : BaseBottomSheet<FragmentBackupDiffBinding>(context) {
override val binder: FragmentBackupDiffBinding = FragmentBackupDiffBinding.inflate(LayoutInflater.from(context))
class Payload(val diffList: List<Any>, val cloudBackupDiff: CloudBackupDiff, val cloudBackup: CloudBackup)
private val adapter by lazy(LazyThreadSafetyMode.NONE) {
CloudBackupDiffAdapter()
}
init {
binder.backupDiffSubtitle.text = buildSubtitleText()
binder.backupDiffList.adapter = adapter
binder.backupDiffCancel.setOnClickListener { dismiss() }
binder.backupDiffApply.setOnClickListener {
onApply(payload.cloudBackupDiff, payload.cloudBackup)
dismiss()
}
adapter.submitList(payload.diffList)
}
private fun buildSubtitleText(): CharSequence {
return SpannableFormatter.format(
context.getString(R.string.backup_diff_subtitle),
context.getString(R.string.backup_diff_subtitle_highlighted).addColor(context.getColor(R.color.text_primary))
)
}
}
@@ -0,0 +1,19 @@
package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff.adapter
import android.graphics.drawable.Drawable
import io.novafoundation.nova.common.view.ChipLabelModel
interface CloudBackupDiffRVItem
class CloudBackupDiffGroupRVItem(
val chipModel: ChipLabelModel
) : CloudBackupDiffRVItem
class AccountDiffRVItem(
val id: String,
val icon: Drawable,
val title: String,
val state: String,
val stateColorRes: Int,
val stateIconRes: Int?,
) : CloudBackupDiffRVItem
@@ -0,0 +1,60 @@
package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff.adapter
import android.view.ViewGroup
import io.novafoundation.nova.common.list.BaseGroupedDiffCallback
import io.novafoundation.nova.common.list.GroupedListAdapter
import io.novafoundation.nova.common.list.GroupedListHolder
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.setDrawableStart
import io.novafoundation.nova.common.view.ChipLabelView
import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountChipHolder
import io.novafoundation.nova.feature_settings_impl.databinding.ItemCloudBackupAccountDiffBinding
class CloudBackupDiffAdapter : GroupedListAdapter<CloudBackupDiffGroupRVItem, AccountDiffRVItem>(BackupAccountDiffCallback()) {
override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder {
return AccountChipHolder(ChipLabelView(parent.context))
}
override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder {
return AccountDiffHolder(ItemCloudBackupAccountDiffBinding.inflate(parent.inflater(), parent, false))
}
override fun bindGroup(holder: GroupedListHolder, group: CloudBackupDiffGroupRVItem) {
(holder as AccountChipHolder).bind(group.chipModel)
}
override fun bindChild(holder: GroupedListHolder, child: AccountDiffRVItem) {
(holder as AccountDiffHolder).bind(child)
}
}
class AccountDiffHolder(private val binder: ItemCloudBackupAccountDiffBinding) : GroupedListHolder(binder.root) {
fun bind(accountModel: AccountDiffRVItem) = with(binder) {
itemCloudBackupAccountDiffIcon.setImageDrawable(accountModel.icon)
itemCloudBackupAccountDiffName.text = accountModel.title
itemCloudBackupAccountDiffState.text = accountModel.state
itemCloudBackupAccountDiffState.setTextColor(root.context.getColor(accountModel.stateColorRes))
itemCloudBackupAccountDiffState.setDrawableStart(accountModel.stateIconRes, paddingInDp = 4)
}
}
private class BackupAccountDiffCallback : BaseGroupedDiffCallback<CloudBackupDiffGroupRVItem, AccountDiffRVItem>(CloudBackupDiffGroupRVItem::class.java) {
override fun areGroupItemsTheSame(oldItem: CloudBackupDiffGroupRVItem, newItem: CloudBackupDiffGroupRVItem): Boolean {
return oldItem.chipModel.title == newItem.chipModel.title
}
override fun areGroupContentsTheSame(oldItem: CloudBackupDiffGroupRVItem, newItem: CloudBackupDiffGroupRVItem): Boolean {
return oldItem.chipModel.title == newItem.chipModel.title
}
override fun areChildItemsTheSame(oldItem: AccountDiffRVItem, newItem: AccountDiffRVItem): Boolean {
return oldItem.id == newItem.id
}
override fun areChildContentsTheSame(oldItem: AccountDiffRVItem, newItem: AccountDiffRVItem): Boolean {
return oldItem.id == newItem.id
}
}
@@ -0,0 +1,68 @@
package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.mixin.actionAwaitable.setupConfirmationDialog
import io.novafoundation.nova.common.mixin.impl.setupCustomDialogDisplayer
import io.novafoundation.nova.common.utils.progress.observeProgressDialog
import io.novafoundation.nova.common.view.bottomSheet.action.observeActionBottomSheet
import io.novafoundation.nova.common.view.input.selector.setupListSelectorMixin
import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.databinding.FragmentBackupSettingsBinding
import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent
import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff.CloudBackupDiffBottomSheet
class BackupSettingsFragment : BaseFragment<BackupSettingsViewModel, FragmentBackupSettingsBinding>() {
override fun createBinding() = FragmentBackupSettingsBinding.inflate(layoutInflater)
override fun initViews() {
binder.backupSettingsToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.backupStateView.setOnClickListener { viewModel.cloudBackupManageClicked() }
binder.backupStateView.setProblemClickListener() { viewModel.problemButtonClicked() }
binder.backupSettingsSwitcher.setOnClickListener { viewModel.backupSwitcherClicked() }
binder.backupSettingsManualBtn.setOnClickListener { viewModel.manualBackupClicked() }
}
override fun inject() {
FeatureUtils.getFeature<SettingsFeatureComponent>(
requireContext(),
SettingsFeatureApi::class.java
)
.backupSettings()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: BackupSettingsViewModel) {
setupCustomDialogDisplayer(viewModel)
observeProgressDialog(viewModel.progressDialogMixin)
observeActionBottomSheet(viewModel)
setupListSelectorMixin(viewModel.listSelectorMixin)
setupConfirmationDialog(R.style.AccentNegativeAlertDialogTheme_Reversed, viewModel.negativeConfirmationAwaitableAction)
setupConfirmationDialog(R.style.AccentAlertDialogTheme, viewModel.neutralConfirmationAwaitableAction)
viewModel.cloudBackupEnabled.observe { enabled ->
binder.backupSettingsSwitcher.setChecked(enabled)
}
viewModel.cloudBackupStateModel.observe { state ->
binder.backupStateView.setState(state)
}
viewModel.cloudBackupChangesLiveData.observeEvent {
showBackupDiffBottomSheet(it)
}
}
private fun showBackupDiffBottomSheet(payload: CloudBackupDiffBottomSheet.Payload) {
val bottomSheet = CloudBackupDiffBottomSheet(
requireContext(),
payload,
onApply = { diff, cloudBackup -> viewModel.applyBackupDestructiveChanges(diff, cloudBackup) }
)
bottomSheet.show()
}
}
@@ -0,0 +1,431 @@
package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.base.showError
import io.novafoundation.nova.common.list.toListWithHeaders
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationDialogInfo
import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction
import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer
import io.novafoundation.nova.common.mixin.api.displayDialogOrNothing
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.flatMap
import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory
import io.novafoundation.nova.common.utils.progress.startProgress
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory
import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor
import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.MetaAccountTypePresentationMapper
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordCommunicator
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordRequester
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordCommunicator
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordRequester
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordRequester
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.CloudBackupDiff
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CannotApplyNonDestructiveDiff
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupNotFound
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.FetchBackupError
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.InvalidBackupPasswordError
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.PasswordNotSaved
import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchCloudBackupChangesAction
import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchCorruptedBackupFoundAction
import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchDeleteBackupAction
import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchDeprecatedPasswordAction
import io.novafoundation.nova.feature_cloud_backup_api.presenter.confirmation.awaitBackupDestructiveChangesConfirmation
import io.novafoundation.nova.feature_cloud_backup_api.presenter.confirmation.awaitDeleteBackupConfirmation
import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.showCloudBackupUnknownError
import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapCloudBackupSyncFailed
import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapDeleteBackupFailureToUi
import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapWriteBackupFailureToUi
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.CloudBackupSettingsInteractor
import io.novafoundation.nova.feature_settings_impl.domain.model.CloudBackupChangedAccount
import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff.CloudBackupDiffBottomSheet
import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff.adapter.AccountDiffRVItem
import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff.adapter.CloudBackupDiffGroupRVItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class BackupSettingsViewModel(
private val resourceManager: ResourceManager,
private val router: SettingsRouter,
private val accountInteractor: AccountInteractor,
private val cloudBackupSettingsInteractor: CloudBackupSettingsInteractor,
private val syncWalletsBackupPasswordCommunicator: SyncWalletsBackupPasswordCommunicator,
private val changeBackupPasswordCommunicator: ChangeBackupPasswordCommunicator,
private val restoreBackupPasswordCommunicator: RestoreBackupPasswordCommunicator,
private val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory,
private val accountTypePresentationMapper: MetaAccountTypePresentationMapper,
private val walletUiUseCase: WalletUiUseCase,
private val progressDialogMixinFactory: ProgressDialogMixinFactory,
actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
listSelectorMixinFactory: ListSelectorMixin.Factory,
customDialogProvider: CustomDialogDisplayer.Presentation
) : BaseViewModel(),
ActionBottomSheetLauncher by actionBottomSheetLauncherFactory.create(),
CustomDialogDisplayer.Presentation by customDialogProvider {
val progressDialogMixin = progressDialogMixinFactory.create()
val negativeConfirmationAwaitableAction = actionAwaitableMixinFactory.confirmingAction<ConfirmationDialogInfo>()
val neutralConfirmationAwaitableAction = actionAwaitableMixinFactory.confirmingAction<ConfirmationDialogInfo>()
val listSelectorMixin = listSelectorMixinFactory.create(viewModelScope)
private val _isSyncing = MutableStateFlow(false)
val isSyncing: Flow<Boolean> = _isSyncing
private val syncedState = MutableStateFlow<BackupSyncOutcome>(BackupSyncOutcome.Ok)
private val lastSync = cloudBackupSettingsInteractor.observeLastSyncedTime()
val cloudBackupEnabled = MutableStateFlow(false)
val cloudBackupStateModel: Flow<CloudBackupStateModel> = combine(
cloudBackupEnabled,
_isSyncing,
syncedState,
lastSync
) { backupEnabled, syncingInProgress, state, lastSync ->
mapCloudBackupStateModel(resourceManager, backupEnabled, syncingInProgress, state, lastSync)
}
private val _cloudBackupChangesLiveData = MutableLiveData<Event<CloudBackupDiffBottomSheet.Payload>>()
val cloudBackupChangesLiveData = _cloudBackupChangesLiveData
init {
syncCloudBackupState()
observeRequesterResults()
launch {
cloudBackupEnabled.value = cloudBackupSettingsInteractor.isSyncCloudBackupEnabled()
}
}
fun backClicked() {
router.back()
}
fun backupSwitcherClicked() {
launch {
if (cloudBackupSettingsInteractor.isSyncCloudBackupEnabled()) {
cloudBackupEnabled.value = false
cloudBackupSettingsInteractor.setCloudBackupSyncEnabled(false)
} else {
cloudBackupEnabled.value = true
syncCloudBackupOnSwitcher()
}
}
}
fun manualBackupClicked() {
launch {
if (accountInteractor.hasSecretsAccounts()) {
router.openManualBackup()
} else {
showError(
resourceManager.getString(R.string.backup_settings_no_wallets_error_title),
resourceManager.getString(R.string.backup_settings_no_wallets_error_message)
)
}
}
}
fun cloudBackupManageClicked() {
if (_isSyncing.value) return
when (syncedState.value) {
BackupSyncOutcome.StorageAuthFailed -> return
BackupSyncOutcome.EmptyPassword,
BackupSyncOutcome.UnknownPassword,
BackupSyncOutcome.CorruptedBackup -> {
listSelectorMixin.showSelector(
R.string.manage_cloud_backup,
listOf(manageBackupDeleteBackupItem())
)
}
BackupSyncOutcome.Ok,
is BackupSyncOutcome.DestructiveDiff,
BackupSyncOutcome.UnknownError -> {
listSelectorMixin.showSelector(
R.string.manage_cloud_backup,
listOf(manageBackupChangePasswordItem(), manageBackupDeleteBackupItem())
)
}
}
}
fun problemButtonClicked() {
when (val value = syncedState.value) {
BackupSyncOutcome.EmptyPassword,
BackupSyncOutcome.UnknownPassword -> openRestorePassword()
BackupSyncOutcome.CorruptedBackup -> showCorruptedBackupActionDialog()
is BackupSyncOutcome.DestructiveDiff -> openCloudBackupDiffScreen(value.cloudBackupDiff, value.cloudBackup)
BackupSyncOutcome.StorageAuthFailed -> initSignInToCloud()
BackupSyncOutcome.UnknownError -> showCloudBackupUnknownError(resourceManager)
BackupSyncOutcome.Ok -> {}
}
}
fun applyBackupDestructiveChanges(cloudBackupDiff: CloudBackupDiff, cloudBackup: CloudBackup) {
launch {
neutralConfirmationAwaitableAction.awaitBackupDestructiveChangesConfirmation(resourceManager)
_isSyncing.value = true
cloudBackupSettingsInteractor.applyBackupAccountDiff(cloudBackupDiff, cloudBackup)
.onSuccess { syncedState.value = BackupSyncOutcome.Ok }
.onFailure { showError(mapWriteBackupFailureToUi(resourceManager, it)) }
_isSyncing.value = false
}
}
private fun openDestructiveDiffAction(diff: CloudBackupDiff, cloudBackup: CloudBackup) {
launchCloudBackupChangesAction(resourceManager) {
openCloudBackupDiffScreen(diff, cloudBackup)
}
}
private fun openCloudBackupDiffScreen(diff: CloudBackupDiff, cloudBackup: CloudBackup) {
launch {
val sortedDiff = cloudBackupSettingsInteractor.prepareSortedLocalChangesFromDiff(diff)
val cloudBackupChangesList = sortedDiff.toListWithHeaders(
keyMapper = { type, _ -> accountTypePresentationMapper.mapTypeToChipLabel(type)?.let { CloudBackupDiffGroupRVItem(it) } },
valueMapper = { mapMetaAccountDiffToUi(it) }
)
_cloudBackupChangesLiveData.value = CloudBackupDiffBottomSheet.Payload(cloudBackupChangesList, diff, cloudBackup).event()
}
}
private fun Throwable.toEnableBackupSyncState(): BackupSyncOutcome {
return when (this) {
is PasswordNotSaved -> BackupSyncOutcome.EmptyPassword
is InvalidBackupPasswordError -> BackupSyncOutcome.UnknownPassword
is CannotApplyNonDestructiveDiff -> BackupSyncOutcome.DestructiveDiff(cloudBackupDiff, cloudBackup)
// not found backup is ok when we enable backup and when we start initial sync since we will create a new backup
is FetchBackupError.BackupNotFound -> BackupSyncOutcome.Ok
is FetchBackupError.CorruptedBackup -> BackupSyncOutcome.CorruptedBackup
is FetchBackupError.Other -> BackupSyncOutcome.UnknownError
is FetchBackupError.AuthFailed -> BackupSyncOutcome.StorageAuthFailed
else -> BackupSyncOutcome.UnknownError
}
}
private fun syncCloudBackupState() = launch {
if (cloudBackupSettingsInteractor.isSyncCloudBackupEnabled()) {
runSyncWithProgress { result ->
result.onFailure { throwable ->
if (throwable is CloudBackupNotFound) {
writeBackupToCloudAndSync()
}
}
}
}
}
private suspend fun writeBackupToCloudAndSync() {
cloudBackupSettingsInteractor.writeLocalBackupToCloud()
.flatMap {
cloudBackupSettingsInteractor.syncCloudBackup()
}.handleSyncBackupResult()
}
private suspend fun syncCloudBackupOnSwitcher() = runSyncWithProgress { result ->
result.onSuccess { cloudBackupSettingsInteractor.setCloudBackupSyncEnabled(true) }
.onFailure { throwable ->
when (throwable) {
is CloudBackupNotFound -> {
syncWalletsBackupPasswordCommunicator.openRequest(SyncWalletsBackupPasswordRequester.EmptyRequest)
}
else -> {
cloudBackupSettingsInteractor.setCloudBackupSyncEnabled(true)
}
}
}
}
private suspend inline fun runSyncWithProgress(action: (Result<Unit>) -> Unit) {
_isSyncing.value = true
val result = cloudBackupSettingsInteractor.syncCloudBackup()
.handleSyncBackupResult()
action(result)
_isSyncing.value = false
}
private fun Result<Unit>.handleSyncBackupResult(): Result<Unit> {
return onSuccess { syncedState.value = BackupSyncOutcome.Ok; }
.onFailure { throwable ->
val state = throwable.toEnableBackupSyncState()
syncedState.value = state
if (state == BackupSyncOutcome.EmptyPassword) {
openRestorePassword()
} else {
handleBackupError(throwable)
}
}
}
private fun handleBackupError(throwable: Throwable) {
val payload = mapCloudBackupSyncFailed(
resourceManager,
throwable,
onDestructiveBackupFound = ::openDestructiveDiffAction,
onPasswordDeprecated = ::showPasswordDeprecatedActionDialog,
onCorruptedBackup = ::showCorruptedBackupActionDialog,
initSignIn = ::initSignInToCloud
)
displayDialogOrNothing(payload)
}
private fun manageBackupChangePasswordItem(): ListSelectorMixin.Item {
return ListSelectorMixin.Item(
R.drawable.ic_pin,
R.color.icon_primary,
R.string.common_change_password,
R.color.text_primary,
::onChangePasswordClicked
)
}
private fun manageBackupDeleteBackupItem(): ListSelectorMixin.Item {
return ListSelectorMixin.Item(
R.drawable.ic_delete,
R.color.icon_negative,
R.string.backup_settings_delete_backup,
R.color.text_negative,
::onDeleteBackupClicked
)
}
private fun onChangePasswordClicked() {
changeBackupPasswordCommunicator.openRequest(ChangeBackupPasswordRequester.EmptyRequest)
}
private fun onDeleteBackupClicked() {
launchDeleteBackupAction(resourceManager, ::confirmCloudBackupDelete)
}
private fun observeRequesterResults() {
changeBackupPasswordCommunicator.responseFlow.syncBackupOnEach()
restoreBackupPasswordCommunicator.responseFlow.syncBackupOnEach()
syncWalletsBackupPasswordCommunicator.responseFlow.syncBackupOnEach()
syncWalletsBackupPasswordCommunicator.responseFlow.onEach { response ->
cloudBackupEnabled.value = cloudBackupSettingsInteractor.isSyncCloudBackupEnabled()
syncedState.value = BackupSyncOutcome.Ok
}.launchIn(this)
}
private fun Flow<Any>.syncBackupOnEach() {
this.onEach {
syncCloudBackupState()
}
.launchIn(this@BackupSettingsViewModel)
}
private fun showPasswordDeprecatedActionDialog() {
launchDeprecatedPasswordAction(resourceManager, ::openRestorePassword)
}
private fun showCorruptedBackupActionDialog() {
launchCorruptedBackupFoundAction(resourceManager, ::confirmCloudBackupDelete)
}
private fun openRestorePassword() {
restoreBackupPasswordCommunicator.openRequest(RestoreBackupPasswordRequester.EmptyRequest)
}
private fun initSignInToCloud() {
launch {
cloudBackupSettingsInteractor.signInToCloud()
.onSuccess { syncCloudBackupState() }
}
}
private fun confirmCloudBackupDelete() {
launch {
negativeConfirmationAwaitableAction.awaitDeleteBackupConfirmation(resourceManager)
progressDialogMixin.startProgress(R.string.deleting_backup_progress) {
runDeleteBackup()
}
}
}
private suspend fun runDeleteBackup() {
cloudBackupSettingsInteractor.deleteCloudBackup()
.onSuccess {
cloudBackupSettingsInteractor.setCloudBackupSyncEnabled(false)
cloudBackupEnabled.value = false
}
.onFailure { throwable ->
val titleAndMessage = mapDeleteBackupFailureToUi(resourceManager, throwable)
titleAndMessage?.let { showError(it) }
}
}
private suspend fun mapMetaAccountDiffToUi(changedAccount: CloudBackupChangedAccount): AccountDiffRVItem {
return with(changedAccount) {
val (stateText, stateColorRes, stateIconRes) = mapChangingTypeToUi(changingType)
val walletIcon = walletUiUseCase.walletIcon(
account.substrateAccountId,
account.ethereumAddress,
account.chainAccounts.map(CloudBackup.WalletPublicInfo.ChainAccountInfo::accountId)
)
AccountDiffRVItem(
id = account.walletId,
icon = walletIcon,
title = account.name,
state = stateText,
stateColorRes = stateColorRes,
stateIconRes = stateIconRes
)
}
}
private fun mapChangingTypeToUi(type: CloudBackupChangedAccount.ChangingType): Triple<String, Int, Int?> {
return when (type) {
CloudBackupChangedAccount.ChangingType.ADDED -> Triple(resourceManager.getString(R.string.state_new), R.color.text_secondary, null)
CloudBackupChangedAccount.ChangingType.REMOVED -> Triple(
resourceManager.getString(R.string.state_removed),
R.color.text_negative,
R.drawable.ic_red_cross
)
CloudBackupChangedAccount.ChangingType.CHANGED -> Triple(
resourceManager.getString(R.string.state_changed),
R.color.text_warning,
R.drawable.ic_warning_filled
)
}
}
}
@@ -0,0 +1,25 @@
package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.CloudBackupDiff
sealed class BackupSyncOutcome {
object Ok : BackupSyncOutcome()
object EmptyPassword : BackupSyncOutcome()
object UnknownPassword : BackupSyncOutcome()
class DestructiveDiff(val cloudBackupDiff: CloudBackupDiff, val cloudBackup: CloudBackup) : BackupSyncOutcome()
object StorageAuthFailed : BackupSyncOutcome()
object CorruptedBackup : BackupSyncOutcome()
object UnknownError : BackupSyncOutcome()
}
fun BackupSyncOutcome.isError(): Boolean {
return this != BackupSyncOutcome.Ok
}
@@ -0,0 +1,103 @@
package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.formatting.formatDateSinceEpoch
import io.novafoundation.nova.common.utils.formatting.formatTime
import io.novafoundation.nova.feature_settings_impl.R
import java.util.Date
fun mapCloudBackupStateModel(
resourceManager: ResourceManager,
backupEnabled: Boolean,
syncingInProgress: Boolean,
syncOutcome: BackupSyncOutcome,
lastSync: Date?
): CloudBackupStateModel {
val (stateImage, stateImageTint, stateBackgroundColor) = mapStateImage(backupEnabled, syncOutcome)
return CloudBackupStateModel(
stateImg = stateImage,
stateImageTint = stateImageTint,
stateColorBackgroundRes = stateBackgroundColor,
showProgress = syncingInProgress,
title = mapCloudBackupStateTitle(resourceManager, backupEnabled, syncingInProgress, syncOutcome),
subtitle = mapCloudBackupStateSubtitle(resourceManager, backupEnabled, lastSync),
isClickable = mapCloudBackupClickability(backupEnabled, syncingInProgress, syncOutcome),
problemButtonText = mapCloudBackupProblemButton(resourceManager, backupEnabled, syncingInProgress, syncOutcome)
)
}
private fun mapStateImage(backupEnabled: Boolean, syncOutcome: BackupSyncOutcome) = when {
!backupEnabled -> Triple(R.drawable.ic_cloud_backup_status_disabled, R.color.icon_secondary, R.color.waiting_status_background)
syncOutcome.isError() -> Triple(R.drawable.ic_cloud_backup_status_warning, R.color.icon_warning, R.color.warning_block_background)
else -> Triple(R.drawable.ic_cloud_backup_status_active, R.color.icon_positive, R.color.active_status_background)
}
private fun mapCloudBackupStateTitle(
resourceManager: ResourceManager,
backupEnabled: Boolean,
syncingInProgress: Boolean,
syncOutcome: BackupSyncOutcome
) = when {
syncingInProgress -> resourceManager.getString(R.string.cloud_backup_state_syncing_title)
!backupEnabled -> resourceManager.getString(R.string.cloud_backup_state_disabled_title)
syncOutcome.isError() -> resourceManager.getString(R.string.cloud_backup_state_unsynced_title)
else -> resourceManager.getString(R.string.cloud_backup_state_synced_title)
}
private fun mapCloudBackupStateSubtitle(
resourceManager: ResourceManager,
backupEnabled: Boolean,
lastSync: Date?
) = when {
!backupEnabled -> resourceManager.getString(R.string.cloud_backup_settings_disabled_state_subtitle)
lastSync != null -> resourceManager.getString(
R.string.cloud_backup_settings_last_sync,
lastSync.formatDateSinceEpoch(resourceManager),
resourceManager.formatTime(lastSync)
)
else -> null
}
private fun mapCloudBackupClickability(
backupEnabled: Boolean,
syncingInProgress: Boolean,
syncOutcome: BackupSyncOutcome
): Boolean {
if (!backupEnabled) return false
if (syncingInProgress) return false
return when (syncOutcome) {
BackupSyncOutcome.Ok,
is BackupSyncOutcome.DestructiveDiff,
BackupSyncOutcome.EmptyPassword,
BackupSyncOutcome.UnknownPassword,
BackupSyncOutcome.CorruptedBackup,
BackupSyncOutcome.UnknownError -> true
BackupSyncOutcome.StorageAuthFailed -> false
}
}
private fun mapCloudBackupProblemButton(
resourceManager: ResourceManager,
backupEnabled: Boolean,
syncingInProgress: Boolean,
syncOutcome: BackupSyncOutcome
): String? {
if (!backupEnabled) return null
if (syncingInProgress) return null
return when (syncOutcome) {
BackupSyncOutcome.Ok -> null
BackupSyncOutcome.CorruptedBackup -> resourceManager.getString(R.string.cloud_backup_settings_backup_errors_button)
is BackupSyncOutcome.DestructiveDiff -> resourceManager.getString(R.string.cloud_backup_settings_corrupted_backup_button)
BackupSyncOutcome.EmptyPassword,
BackupSyncOutcome.UnknownPassword -> resourceManager.getString(R.string.cloud_backup_settings_deprecated_password_button)
BackupSyncOutcome.StorageAuthFailed -> resourceManager.getString(R.string.cloud_backup_settings_not_auth_button)
BackupSyncOutcome.UnknownError -> resourceManager.getString(R.string.cloud_backup_settings_other_errors_button)
}
}
@@ -0,0 +1,24 @@
package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings
import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings.views.CloudBackupStateView
class CloudBackupStateModel(
val stateImg: Int?,
val stateImageTint: Int,
val stateColorBackgroundRes: Int?,
val showProgress: Boolean,
val title: String,
val subtitle: String?,
val isClickable: Boolean,
val problemButtonText: String?
)
fun CloudBackupStateView.setState(state: CloudBackupStateModel) {
setStateImage(state.stateImg, state.stateImageTint, state.stateColorBackgroundRes)
setProgressVisibility(state.showProgress)
setTitle(state.title)
setSubtitle(state.subtitle)
isClickable = state.isClickable
showMoreButton(state.isClickable)
setProblemText(state.problemButtonText)
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings.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_settings_impl.presentation.cloudBackup.settings.BackupSettingsFragment
@Subcomponent(
modules = [
CloudBackupSettingsModule::class
]
)
@ScreenScope
interface CloudBackupSettingsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment
): CloudBackupSettingsComponent
}
fun inject(fragment: BackupSettingsFragment)
}
@@ -0,0 +1,74 @@
package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings.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.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory
import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory
import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor
import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.MetaAccountTypePresentationMapper
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordCommunicator
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordCommunicator
import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.CloudBackupSettingsInteractor
import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings.BackupSettingsViewModel
@Module(includes = [ViewModelModule::class])
class CloudBackupSettingsModule {
@Provides
@IntoMap
@ViewModelKey(BackupSettingsViewModel::class)
fun provideViewModel(
resourceManager: ResourceManager,
router: SettingsRouter,
accountInteractor: AccountInteractor,
cloudBackupSettingsInteractor: CloudBackupSettingsInteractor,
syncWalletsBackupPasswordCommunicator: SyncWalletsBackupPasswordCommunicator,
changeBackupPasswordCommunicator: ChangeBackupPasswordCommunicator,
restoreBackupPasswordCommunicator: RestoreBackupPasswordCommunicator,
actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory,
accountTypePresentationMapper: MetaAccountTypePresentationMapper,
walletUiUseCase: WalletUiUseCase,
progressDialogMixinFactory: ProgressDialogMixinFactory,
actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
listSelectorMixinFactory: ListSelectorMixin.Factory,
customDialogProvider: CustomDialogDisplayer.Presentation
): ViewModel {
return BackupSettingsViewModel(
resourceManager,
router,
accountInteractor,
cloudBackupSettingsInteractor,
syncWalletsBackupPasswordCommunicator,
changeBackupPasswordCommunicator,
restoreBackupPasswordCommunicator,
actionBottomSheetLauncherFactory,
accountTypePresentationMapper,
walletUiUseCase,
progressDialogMixinFactory,
actionAwaitableMixinFactory,
listSelectorMixinFactory,
customDialogProvider
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): BackupSettingsViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(BackupSettingsViewModel::class.java)
}
}
@@ -0,0 +1,89 @@
package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings.views
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isGone
import androidx.core.view.isVisible
import io.novafoundation.nova.common.utils.getRippleMask
import io.novafoundation.nova.common.utils.getRoundedCornerDrawable
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.setImageTint
import io.novafoundation.nova.common.utils.setTextOrHide
import io.novafoundation.nova.common.utils.withRippleMask
import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable
import io.novafoundation.nova.common.view.shape.ovalDrawable
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.databinding.ViewCloudBackupStateBinding
class CloudBackupStateView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
) : ConstraintLayout(context, attrs, defStyle) {
private val binder = ViewCloudBackupStateBinding.inflate(inflater(), this, true)
private val stateImage: ImageView
get() = binder.backupStateImg
private val progress: ProgressBar
get() = binder.backupStateProgress
private val title: TextView
get() = binder.backupStateTitle
private val subtitle: TextView
get() = binder.backupStateSubtitle
private val more: View
get() = binder.backupStateMore
private val divider: View
get() = binder.backupStateDivider
private val problemButton: TextView
get() = binder.backupStateProblemBtn
init {
background = getRoundedCornerDrawable(fillColorRes = R.color.block_background).withRippleMask()
problemButton.background = context.getRoundedCornerDrawable(fillColorRes = null).withRippleMask(getRippleMask(cornerSizeDp = 8))
}
fun setStateImage(resId: Int?, stateImageTiniRes: Int?, backgroundColorRes: Int?) {
resId?.let { stateImage.setImageResource(it) } ?: stateImage.setImageDrawable(null)
stateImage.setImageTint(stateImageTiniRes?.let { context.getColor(it) })
stateImage.background = backgroundColorRes?.let { ovalDrawable(context.getColor(it)) }
}
fun setProgressVisibility(visible: Boolean) {
progress.isVisible = visible
stateImage.isGone = visible
}
fun setTitle(titleText: String) {
title.text = titleText
}
fun setSubtitle(text: String?) {
subtitle.setTextOrHide(text)
}
fun setProblemText(text: String?) {
divider.isVisible = text != null
problemButton.isVisible = text != null
problemButton.text = text
}
fun setProblemClickListener(listener: OnClickListener?) {
problemButton.setOnClickListener(listener)
}
fun showMoreButton(show: Boolean) {
more.isVisible = show
}
}
@@ -0,0 +1,53 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main
import android.os.Bundle
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.makeGone
import io.novafoundation.nova.common.utils.setupWithViewPager2
import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi
import io.novafoundation.nova.feature_settings_impl.databinding.FragmentAddNetworkMainBinding
import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent
class AddNetworkMainFragment : BaseFragment<AddNetworkMainViewModel, FragmentAddNetworkMainBinding>() {
companion object {
private const val KEY_PAYLOAD = "key_payload"
fun getBundle(payload: AddNetworkPayload): Bundle {
return Bundle().apply {
putParcelable(KEY_PAYLOAD, payload)
}
}
}
override fun createBinding() = FragmentAddNetworkMainBinding.inflate(layoutInflater)
override fun initViews() {
binder.addNetworkMainToolbar.setHomeButtonListener { viewModel.backClicked() }
val payload: AddNetworkPayload? = argumentOrNull(KEY_PAYLOAD)
val adapter = AddNetworkMainPagerAdapter(this, payload)
binder.addNetworkMainViewPager.adapter = adapter
if (payload == null) {
binder.addNetworkMainTabLayout.setupWithViewPager2(binder.addNetworkMainViewPager, adapter::getPageTitle)
} else {
binder.addNetworkMainTabLayout.makeGone()
}
}
override fun inject() {
FeatureUtils.getFeature<SettingsFeatureComponent>(
requireContext(),
SettingsFeatureApi::class.java
)
.addNetworkMainFactory()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: AddNetworkMainViewModel) {
}
}
@@ -0,0 +1,55 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload.Mode.Add.NetworkType
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails.AddNetworkFragment
class AddNetworkMainPagerAdapter(
private val fragment: Fragment,
private val payloadForSinglePage: AddNetworkPayload?
) : FragmentStateAdapter(fragment) {
override fun getItemCount(): Int {
return if (payloadForSinglePage == null) {
2
} else {
1
}
}
override fun createFragment(position: Int): Fragment {
return if (payloadForSinglePage == null) {
when (position) {
0 -> AddNetworkFragment().addPayloadEmptyMode(NetworkType.SUBSTRATE)
1 -> AddNetworkFragment().addPayloadEmptyMode(NetworkType.EVM)
else -> throw IllegalArgumentException("Invalid position")
}
} else {
AddNetworkFragment().addPayload(payloadForSinglePage)
}
}
fun getPageTitle(position: Int): CharSequence {
return if (payloadForSinglePage == null) {
when (position) {
0 -> fragment.getString(R.string.common_substrate)
1 -> fragment.getString(R.string.common_evm)
else -> throw IllegalArgumentException("Invalid position")
}
} else {
"" // For single page we hide TabLayout
}
}
private fun AddNetworkFragment.addPayloadEmptyMode(networkType: NetworkType): AddNetworkFragment {
return addPayload(AddNetworkPayload(AddNetworkPayload.Mode.Add(networkType, null)))
}
private fun AddNetworkFragment.addPayload(mode: AddNetworkPayload): AddNetworkFragment {
this.arguments = AddNetworkFragment.getBundle(mode)
return this
}
}
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
class AddNetworkMainViewModel(
private val router: SettingsRouter
) : BaseViewModel() {
fun backClicked() {
router.back()
}
}
@@ -0,0 +1,31 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main
import android.os.Parcelable
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.util.ChainParcel
import kotlinx.parcelize.Parcelize
@Parcelize
class AddNetworkPayload(
val mode: Mode,
) : Parcelable {
sealed interface Mode : Parcelable {
@Parcelize
class Add(val networkType: NetworkType, val chainParcel: ChainParcel?) : Mode {
enum class NetworkType {
SUBSTRATE, EVM
}
}
@Parcelize
class Edit(val chainId: String) : Mode
}
}
fun AddNetworkPayload.Mode.Add.getChain(): Chain? {
return chainParcel?.chain
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.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_settings_impl.presentation.networkManagement.add.main.AddNetworkMainFragment
@Subcomponent(
modules = [
AddNetworkMainModule::class
]
)
@ScreenScope
interface AddNetworkMainComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment
): AddNetworkMainComponent
}
fun inject(fragment: AddNetworkMainFragment)
}
@@ -0,0 +1,32 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.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.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkMainViewModel
@Module(includes = [ViewModelModule::class])
class AddNetworkMainModule {
@Provides
@IntoMap
@ViewModelKey(AddNetworkMainViewModel::class)
fun provideViewModel(
router: SettingsRouter
): ViewModel {
return AddNetworkMainViewModel(
router
)
}
@Provides
fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): AddNetworkMainViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(AddNetworkMainViewModel::class.java)
}
}
@@ -0,0 +1,59 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails
import android.os.Bundle
import androidx.core.view.isVisible
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.mixin.impl.observeValidations
import io.novafoundation.nova.common.utils.bindTo
import io.novafoundation.nova.common.view.setState
import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi
import io.novafoundation.nova.feature_settings_impl.databinding.FragmentAddNetworkBinding
import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload
class AddNetworkFragment : BaseFragment<AddNetworkViewModel, FragmentAddNetworkBinding>() {
companion object {
private const val KEY_PAYLOAD = "key_payload"
fun getBundle(payload: AddNetworkPayload): Bundle {
return Bundle().apply {
putParcelable(KEY_PAYLOAD, payload)
}
}
}
override fun createBinding() = FragmentAddNetworkBinding.inflate(layoutInflater)
override fun inject() {
FeatureUtils.getFeature<SettingsFeatureComponent>(
requireContext(),
SettingsFeatureApi::class.java
)
.addNetworkFactory()
.create(this, argument(KEY_PAYLOAD))
.inject(this)
}
override fun initViews() {
binder.addNetworkApplyButton.prepareForProgress(lifecycleOwner)
binder.addNetworkApplyButton.setOnClickListener { viewModel.addNetworkClicked() }
}
override fun subscribe(viewModel: AddNetworkViewModel) {
observeValidations(viewModel)
viewModel.isNodeEditable.observe { binder.addNetworkNodeUrl.isEnabled = it }
binder.addNetworkNodeUrl.bindTo(viewModel.nodeUrlFlow, viewModel)
binder.addNetworkName.bindTo(viewModel.networkNameFlow, viewModel)
binder.addNetworkCurrency.bindTo(viewModel.tokenSymbolFlow, viewModel)
viewModel.isChainIdVisibleFlow.observe { binder.addNetworkChainIdContainer.isVisible = it }
binder.addNetworkChainId.bindTo(viewModel.evmChainIdFlow, viewModel)
binder.addNetworkBlockExplorer.bindTo(viewModel.blockExplorerFlow, viewModel)
binder.addNetworkPriceInfoProvider.bindTo(viewModel.priceProviderFlow, viewModel)
viewModel.buttonState.observe { binder.addNetworkApplyButton.setState(it) }
}
}
@@ -0,0 +1,250 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails
import android.util.Log
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser
import io.novafoundation.nova.common.mixin.api.Validatable
import io.novafoundation.nova.common.presentation.DescriptiveButtonState
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.formatting.format
import io.novafoundation.nova.common.utils.nullIfBlank
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.validation.progressConsumer
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.AddNetworkInteractor
import io.novafoundation.nova.feature_settings_impl.domain.model.CustomNetworkPayload
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.CustomNetworkValidationSystem
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload.Mode
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.getChain
import io.novafoundation.nova.runtime.ext.evmChainIdOrNull
import io.novafoundation.nova.runtime.ext.networkType
import io.novafoundation.nova.runtime.ext.normalizedUrl
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.NetworkType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
class AddNetworkViewModel(
private val resourceManager: ResourceManager,
private val router: SettingsRouter,
private val payload: AddNetworkPayload,
private val interactor: AddNetworkInteractor,
private val validationExecutor: ValidationExecutor,
private val autofillNetworkMetadataMixinFactory: AutofillNetworkMetadataMixinFactory,
private val coinGeckoLinkParser: CoinGeckoLinkParser,
private val chainRegistry: ChainRegistry
) : BaseViewModel(), Validatable by validationExecutor {
private val networkType = flowOf { getNetworkType() }
.shareInBackground()
val isChainIdVisibleFlow = flowOf { isChainIdFieldRequired() }
.shareInBackground()
val isNodeEditable = flowOf { payload.mode is Mode.Add }
.shareInBackground()
val nodeUrlFlow = MutableStateFlow("")
val networkNameFlow = MutableStateFlow("")
val tokenSymbolFlow = MutableStateFlow("")
val evmChainIdFlow = MutableStateFlow("")
val blockExplorerFlow = MutableStateFlow("")
val priceProviderFlow = MutableStateFlow("")
private val loadingState = MutableStateFlow(false)
val buttonState = combine(
nodeUrlFlow,
networkNameFlow,
tokenSymbolFlow,
evmChainIdFlow,
loadingState
) { url, networkName, tokenName, chainId, isLoading ->
val chainIdRequiredAndEmpty = isChainIdFieldRequired() && chainId.isBlank()
when {
isLoading -> DescriptiveButtonState.Loading
url.isBlank() || networkName.isBlank() || tokenName.isBlank() || chainIdRequiredAndEmpty -> DescriptiveButtonState.Disabled(
resourceManager.getString(R.string.common_enter_details)
)
else -> {
val resId = when (payload.mode) {
is Mode.Add -> R.string.common_add_network
is Mode.Edit -> R.string.common_save
}
DescriptiveButtonState.Enabled(resourceManager.getString(resId))
}
}
}
init {
launch {
prefillData()
runAutofill()
}
}
private suspend fun prefillData() {
val mode = payload.mode
val chain = when (mode) {
is Mode.Add -> mode.getChain()
is Mode.Edit -> chainRegistry.getChain(mode.chainId)
}
val asset = chain?.utilityAsset
nodeUrlFlow.value = chain?.nodes?.nodes?.first()?.unformattedUrl.orEmpty()
networkNameFlow.value = chain?.name.orEmpty()
tokenSymbolFlow.value = asset?.symbol?.value.orEmpty()
evmChainIdFlow.value = chain?.evmChainIdOrNull()?.format().orEmpty()
blockExplorerFlow.value = chain?.explorers?.firstOrNull()?.normalizedUrl().orEmpty()
priceProviderFlow.value = asset?.priceId?.let { coinGeckoLinkParser.format(it) }.orEmpty()
}
fun addNetworkClicked() {
launch {
val chainName = networkNameFlow.value
val blockExplorerUrl = blockExplorerFlow.value.nullIfBlank()
val blockExplorerName = resourceManager.getString(R.string.create_network_block_explorer_name, chainName)
val validationPayload = CustomNetworkPayload(
nodeUrl = nodeUrlFlow.value,
nodeName = resourceManager.getString(R.string.create_network_node_name, chainName),
chainName = chainName,
tokenSymbol = tokenSymbolFlow.value,
evmChainId = evmChainIdFlow.value.toIntOrNull(),
blockExplorer = blockExplorerUrl?.let { CustomNetworkPayload.BlockExplorer(blockExplorerName, it) },
coingeckoLinkUrl = priceProviderFlow.value.nullIfBlank(),
ignoreChainModifying = payload.mode is Mode.Edit // Skip dialog about Chain modifying if it's already editing
)
validationExecutor.requireValid(
validationSystem = getValidationSystem(),
payload = validationPayload,
progressConsumer = loadingState.progressConsumer(),
validationFailureTransformerCustom = { status, actions ->
mapSaveCustomNetworkFailureToUI(
resourceManager,
status,
actions,
::changeTokenSymbol
)
}
) {
executeSaving(validationPayload)
}
}
}
private fun executeSaving(savingPayload: CustomNetworkPayload) {
launch {
val result = when (payload.mode) {
is Mode.Add -> createNetwork(payload.mode, savingPayload)
is Mode.Edit -> editNetwork(payload.mode, savingPayload)
}
result.onSuccess { finishSavingFlow() }
.onFailure {
Log.e(LOG_TAG, "Failed to save network", it)
showError(resourceManager.getString(R.string.common_something_went_wrong_title))
}
loadingState.value = false
}
}
private fun finishSavingFlow() {
launch {
when (payload.mode) {
is Mode.Add -> router.finishCreateNetworkFlow()
is Mode.Edit -> router.back()
}
}
}
private suspend fun createNetwork(mode: Mode.Add, savingPayload: CustomNetworkPayload): Result<Unit> {
return when (mode.networkType) {
Mode.Add.NetworkType.EVM -> interactor.createEvmNetwork(savingPayload, mode.getChain())
Mode.Add.NetworkType.SUBSTRATE -> interactor.createSubstrateNetwork(savingPayload, mode.getChain(), coroutineScope)
}
}
private suspend fun editNetwork(mode: Mode.Edit, savingPayload: CustomNetworkPayload): Result<Unit> {
return interactor.updateChain(
mode.chainId,
savingPayload.chainName,
savingPayload.tokenSymbol,
savingPayload.blockExplorer,
savingPayload.coingeckoLinkUrl
)
}
private fun changeTokenSymbol(symbol: String) {
tokenSymbolFlow.value = symbol
}
private suspend fun getValidationSystem(): CustomNetworkValidationSystem {
return when (networkType.first()) {
NetworkType.EVM -> interactor.getEvmValidationSystem(viewModelScope)
NetworkType.SUBSTRATE -> interactor.getSubstrateValidationSystem(viewModelScope)
}
}
private fun runAutofill() {
launch {
val autofillMixin = when (networkType.first()) {
NetworkType.EVM -> autofillNetworkMetadataMixinFactory.evm(viewModelScope)
NetworkType.SUBSTRATE -> autofillNetworkMetadataMixinFactory.substrate(viewModelScope)
}
nodeUrlFlow
.ignorePrefilledValues()
.debounce(500.milliseconds)
.mapLatest { url -> autofillMixin.autofill(url) }
.onEach { result ->
result.onSuccess { data ->
data.chainName?.let { networkNameFlow.value = it }
data.tokenSymbol?.let { tokenSymbolFlow.value = it }
data.evmChainId?.let { evmChainIdFlow.value = it.toString() }
}
}
.launchIn(viewModelScope)
}
}
private fun <T> Flow<T>.ignorePrefilledValues(): Flow<T> {
return this.drop(1)
}
private fun isChainIdFieldRequired(): Boolean {
return payload.mode is Mode.Add && payload.mode.networkType == Mode.Add.NetworkType.EVM
}
private suspend fun getNetworkType(): NetworkType {
return when (payload.mode) {
is Mode.Add -> {
when (payload.mode.networkType) {
Mode.Add.NetworkType.SUBSTRATE -> NetworkType.SUBSTRATE
Mode.Add.NetworkType.EVM -> NetworkType.EVM
}
}
is Mode.Edit -> chainRegistry.getChain(payload.mode.chainId).networkType()
}
}
}
@@ -0,0 +1,105 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails
import io.novafoundation.nova.common.data.network.runtime.calls.GetChainRequest
import io.novafoundation.nova.common.data.network.runtime.calls.GetSystemPropertiesRequest
import io.novafoundation.nova.common.data.network.runtime.model.SystemProperties
import io.novafoundation.nova.common.data.network.runtime.model.firstTokenSymbol
import io.novafoundation.nova.core.ethereum.Web3Api
import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory
import io.novafoundation.nova.runtime.ethereum.sendSuspend
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnection
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory
import io.novasama.substrate_sdk_android.wsrpc.executeAsync
import io.novasama.substrate_sdk_android.wsrpc.mappers.nonNull
import io.novasama.substrate_sdk_android.wsrpc.mappers.pojo
import java.math.BigInteger
import kotlinx.coroutines.CoroutineScope
class AutofillNetworkData(
val chainName: String?,
val tokenSymbol: String?,
val evmChainId: BigInteger?
)
class AutofillNetworkMetadataMixinFactory(
private val nodeConnectionFactory: NodeConnectionFactory,
private val web3ApiFactory: Web3ApiFactory
) {
fun substrate(coroutineScope: CoroutineScope): SubstrateAutofillNetworkMetadataMixin {
return SubstrateAutofillNetworkMetadataMixin(nodeConnectionFactory, coroutineScope)
}
fun evm(coroutineScope: CoroutineScope): EvmAutofillNetworkMetadataMixin {
return EvmAutofillNetworkMetadataMixin(nodeConnectionFactory, coroutineScope, web3ApiFactory)
}
}
interface AutofillNetworkMetadataMixin {
suspend fun autofill(url: String): Result<AutofillNetworkData>
}
class SubstrateAutofillNetworkMetadataMixin(
private val nodeConnectionFactory: NodeConnectionFactory,
private val coroutineScope: CoroutineScope
) : AutofillNetworkMetadataMixin {
private var nodeConnection: NodeConnection? = null
override suspend fun autofill(url: String): Result<AutofillNetworkData> = runCatching {
if (nodeConnection == null) {
nodeConnection = nodeConnectionFactory.createNodeConnection(url, coroutineScope)
} else {
nodeConnection!!.switchUrl(url)
}
val properties = getSubstrateChainProperties(nodeConnection!!)
val chainName = getSubstrateChainName(nodeConnection!!)
AutofillNetworkData(
chainName = chainName,
tokenSymbol = properties.firstTokenSymbol(),
evmChainId = null
)
}
private suspend fun getSubstrateChainProperties(nodeConnection: NodeConnection): SystemProperties {
return nodeConnection.getSocketService()
.executeAsync(GetSystemPropertiesRequest(), mapper = pojo<SystemProperties>().nonNull())
}
private suspend fun getSubstrateChainName(nodeConnection: NodeConnection): String {
return nodeConnection.getSocketService()
.executeAsync(GetChainRequest(), mapper = pojo<String>().nonNull())
}
}
class EvmAutofillNetworkMetadataMixin(
private val nodeConnectionFactory: NodeConnectionFactory,
private val coroutineScope: CoroutineScope,
private val web3ApiFactory: Web3ApiFactory
) : AutofillNetworkMetadataMixin {
private var nodeConnection: NodeConnection? = null
private var web3Api: Web3Api? = null
override suspend fun autofill(url: String): Result<AutofillNetworkData> = runCatching {
if (nodeConnection == null) {
nodeConnection = nodeConnectionFactory.createNodeConnection(url, coroutineScope)
} else {
nodeConnection!!.switchUrl(url)
}
if (web3Api == null) {
web3Api = web3ApiFactory.createWss(nodeConnection!!.getSocketService())
}
val chainId = web3Api!!.ethChainId().sendSuspend().chainId
AutofillNetworkData(
chainName = null,
tokenSymbol = null,
evmChainId = chainId
)
}
}
@@ -0,0 +1,82 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails
import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.validation.TransformedFailure
import io.novafoundation.nova.common.validation.TransformedFailure.Default
import io.novafoundation.nova.common.validation.ValidationFlowActions
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.CustomNetworkFailure
import io.novafoundation.nova.feature_settings_impl.domain.model.CustomNetworkPayload
fun mapSaveCustomNetworkFailureToUI(
resourceManager: ResourceManager,
status: ValidationStatus.NotValid<CustomNetworkFailure>,
actions: ValidationFlowActions<CustomNetworkPayload>,
changeTokenSymbolAction: (String) -> Unit
): TransformedFailure {
return when (val reason = status.reason) {
is CustomNetworkFailure.DefaultNetworkAlreadyAdded -> Default(
resourceManager.getString(R.string.network_already_exist_failure_title) to
resourceManager.getString(R.string.default_network_already_exist_failure_message, reason.networkName)
)
is CustomNetworkFailure.CustomNetworkAlreadyAdded -> {
TransformedFailure.Custom(
CustomDialogDisplayer.Payload(
title = resourceManager.getString(R.string.network_already_exist_failure_title),
message = resourceManager.getString(R.string.custom_network_already_exist_failure_message, reason.networkName),
cancelAction = CustomDialogDisplayer.Payload.DialogAction(
title = resourceManager.getString(R.string.common_close),
action = { }
),
okAction = CustomDialogDisplayer.Payload.DialogAction(
title = resourceManager.getString(R.string.common_modify),
action = {
actions.revalidate { payload ->
payload.copy(ignoreChainModifying = true)
}
}
),
customStyle = R.style.AccentAlertDialogTheme
)
)
}
CustomNetworkFailure.NodeIsNotAlive -> Default(
resourceManager.getString(R.string.node_not_alive_title) to
resourceManager.getString(R.string.node_not_alive_message)
)
is CustomNetworkFailure.WrongNetwork -> Default(
resourceManager.getString(R.string.create_network_invalid_chain_id_title) to
resourceManager.getString(R.string.create_network_invalid_chain_id_message)
)
CustomNetworkFailure.CoingeckoLinkBadFormat -> Default(
resourceManager.getString(R.string.asset_add_evm_token_invalid_coin_gecko_link_title) to
resourceManager.getString(R.string.asset_add_evm_token_invalid_coin_gecko_link_message)
)
is CustomNetworkFailure.WrongAsset -> {
TransformedFailure.Custom(
CustomDialogDisplayer.Payload(
title = resourceManager.getString(R.string.invalid_token_symbol_title),
message = resourceManager.getString(R.string.invalid_token_symbol_message, reason.usedSymbol, reason.correctSymbol),
cancelAction = CustomDialogDisplayer.Payload.DialogAction(
title = resourceManager.getString(R.string.common_close),
action = { }
),
okAction = CustomDialogDisplayer.Payload.DialogAction(
title = resourceManager.getString(R.string.common_apply),
action = {
changeTokenSymbolAction(reason.correctSymbol)
}
),
customStyle = R.style.AccentAlertDialogTheme
)
)
}
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails.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_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails.AddNetworkFragment
@Subcomponent(
modules = [
AddNetworkModule::class
]
)
@ScreenScope
interface AddNetworkComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance argument: AddNetworkPayload,
): AddNetworkComponent
}
fun inject(fragment: AddNetworkFragment)
}
@@ -0,0 +1,66 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails.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.data.network.coingecko.CoinGeckoLinkParser
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.common.validation.ValidationExecutor
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.AddNetworkInteractor
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails.AddNetworkViewModel
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails.AutofillNetworkMetadataMixinFactory
import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory
@Module(includes = [ViewModelModule::class])
class AddNetworkModule {
@Provides
fun provideAutofillNetworkMetadataMixinFactory(
nodeConnectionFactory: NodeConnectionFactory,
web3ApiFactory: Web3ApiFactory
): AutofillNetworkMetadataMixinFactory {
return AutofillNetworkMetadataMixinFactory(
nodeConnectionFactory,
web3ApiFactory
)
}
@Provides
@IntoMap
@ViewModelKey(AddNetworkViewModel::class)
fun provideViewModel(
resourceManager: ResourceManager,
router: SettingsRouter,
payload: AddNetworkPayload,
interactor: AddNetworkInteractor,
validationExecutor: ValidationExecutor,
autofillNetworkMetadataMixinFactory: AutofillNetworkMetadataMixinFactory,
coinGeckoLinkParser: CoinGeckoLinkParser,
chainRegistry: ChainRegistry,
): ViewModel {
return AddNetworkViewModel(
resourceManager = resourceManager,
router = router,
payload = payload,
interactor = interactor,
validationExecutor = validationExecutor,
autofillNetworkMetadataMixinFactory = autofillNetworkMetadataMixinFactory,
coinGeckoLinkParser = coinGeckoLinkParser,
chainRegistry = chainRegistry
)
}
@Provides
fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): AddNetworkViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(AddNetworkViewModel::class.java)
}
}
@@ -0,0 +1,166 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain
import android.os.Bundle
import androidx.recyclerview.widget.ConcatAdapter
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.ViewSpace
import io.novafoundation.nova.common.view.dialog.warningDialog
import io.novafoundation.nova.common.view.input.selector.setupListSelectorMixin
import io.novafoundation.nova.common.view.recyclerview.adapter.text.TextAdapter
import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.databinding.FragmentChainNetworkManagementBinding
import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.headerAdapter.ChainNetworkManagementHeaderAdapter
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.ChainNetworkManagementNodesAdapter
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.NodesItemBackgroundDecoration
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.NodesItemDividerDecoration
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items.NetworkNodeRvItem
import javax.inject.Inject
private val NODES_GROUP_TEXT_STYLE = R.style.TextAppearance_NovaFoundation_SemiBold_Caps2
private val NODES_GROUP_COLOR_RES = R.color.text_secondary
private val NODES_GROUP_TEXT_PADDING_DP = ViewSpace(16, 24, 16, 0)
class ChainNetworkManagementFragment :
BaseFragment<ChainNetworkManagementViewModel, FragmentChainNetworkManagementBinding>(),
ChainNetworkManagementHeaderAdapter.ItemHandler,
ChainNetworkManagementNodesAdapter.ItemHandler {
companion object {
private const val EXTRA_PAYLOAD = "payload"
fun getBundle(payload: ChainNetworkManagementPayload): Bundle {
return Bundle().apply {
putParcelable(EXTRA_PAYLOAD, payload)
}
}
}
override fun createBinding() = FragmentChainNetworkManagementBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
private val headerAdapter by lazy { ChainNetworkManagementHeaderAdapter(imageLoader, this) }
private val customNodesTitleAdapter by lazy {
TextAdapter(
requireContext().getString(R.string.network_management_custom_nodes),
NODES_GROUP_TEXT_STYLE,
NODES_GROUP_COLOR_RES,
NODES_GROUP_TEXT_PADDING_DP
)
}
private val customNodes by lazy { ChainNetworkManagementNodesAdapter(this) }
private val defaultNodesTitleAdapter by lazy {
TextAdapter(
requireContext().getString(R.string.network_management_default_nodes),
NODES_GROUP_TEXT_STYLE,
NODES_GROUP_COLOR_RES,
NODES_GROUP_TEXT_PADDING_DP
)
}
private val defaultNodes by lazy { ChainNetworkManagementNodesAdapter(this) }
private val adapter by lazy {
ConcatAdapter(
headerAdapter,
customNodesTitleAdapter,
customNodes,
defaultNodesTitleAdapter,
defaultNodes
)
}
override fun initViews() {
binder.chainNetworkManagementToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.chainNetworkManagementToolbar.setRightActionClickListener { viewModel.networkActionsClicked() }
binder.chainNetworkManagementContent.adapter = adapter
binder.chainNetworkManagementContent.itemAnimator = null
binder.chainNetworkManagementContent.addItemDecoration(NodesItemBackgroundDecoration(requireContext()))
binder.chainNetworkManagementContent.addItemDecoration(NodesItemDividerDecoration(requireContext()))
}
override fun inject() {
FeatureUtils.getFeature<SettingsFeatureComponent>(
requireContext(),
SettingsFeatureApi::class.java
)
.chainNetworkManagementFactory()
.create(this, argument(EXTRA_PAYLOAD))
.inject(this)
}
override fun subscribe(viewModel: ChainNetworkManagementViewModel) {
setupListSelectorMixin(viewModel.listSelectorMixin)
viewModel.isNetworkEditable.observe {
if (it) {
binder.chainNetworkManagementToolbar.setRightActionTint(R.color.icon_primary)
binder.chainNetworkManagementToolbar.setRightIconRes(R.drawable.ic_more_horizontal)
}
}
viewModel.isNetworkCanBeDisabled.observe {
headerAdapter.setNetworkCanBeDisabled(it)
}
viewModel.chainEnabled.observe {
headerAdapter.setChainEnabled(it)
}
viewModel.autoBalanceEnabled.observe {
headerAdapter.setAutoBalanceEnabled(it)
}
viewModel.chainModel.observe {
headerAdapter.setChainUiModel(it)
}
viewModel.customNodes.observe {
customNodes.submitList(it)
}
viewModel.defaultNodes.observe {
defaultNodesTitleAdapter.show(it.isNotEmpty())
defaultNodes.submitList(it)
}
viewModel.confirmAccountDeletion.awaitableActionLiveData.observeEvent {
warningDialog(
requireContext(),
onPositiveClick = { it.onSuccess(true) },
onNegativeClick = { it.onSuccess(false) },
positiveTextRes = R.string.common_delete
) {
setTitle(it.payload.first)
setMessage(it.payload.second)
}
}
}
override fun chainEnableClicked() {
viewModel.chainEnableClicked()
}
override fun autoBalanceClicked() {
viewModel.autoBalanceClicked()
}
override fun selectNode(item: NetworkNodeRvItem) {
viewModel.selectNode(item)
}
override fun editNode(item: NetworkNodeRvItem) {
viewModel.nodeActionClicked(item)
}
override fun addNewNode() {
viewModel.addNewNode()
}
}
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class ChainNetworkManagementPayload(
val chainId: String
) : Parcelable
@@ -0,0 +1,229 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingOrDenyingAction
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.ChainNetworkState
import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementChainInteractor
import io.novafoundation.nova.feature_settings_impl.domain.NodeHealthState
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items.NetworkConnectionRvItem
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items.NetworkNodeRvItem
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items.NetworkNodesAddCustomRvItem
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.common.ConnectionStateModel
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.CustomNodePayload
import io.novafoundation.nova.runtime.ext.autoBalanceEnabled
import io.novafoundation.nova.runtime.ext.isCustomNetwork
import io.novafoundation.nova.runtime.ext.isEnabled
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class ChainNetworkManagementViewModel(
private val router: SettingsRouter,
private val resourceManager: ResourceManager,
private val networkManagementChainInteractor: NetworkManagementChainInteractor,
private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
private val listSelectorMixinFactory: ListSelectorMixin.Factory,
private val payload: ChainNetworkManagementPayload
) : BaseViewModel() {
private val chainNetworkStateFlow = networkManagementChainInteractor.chainStateFlow(payload.chainId, viewModelScope)
.shareInBackground()
val isNetworkCanBeDisabled: Flow<Boolean> = chainNetworkStateFlow.map { it.networkCanBeDisabled }
val chainEnabled: Flow<Boolean> = chainNetworkStateFlow.map { it.chain.isEnabled }
val autoBalanceEnabled: Flow<Boolean> = chainNetworkStateFlow.map { it.chain.autoBalanceEnabled }
val chainModel: Flow<ChainUi> = chainNetworkStateFlow.map { mapChainToUi(it.chain) }
val isNetworkEditable = chainNetworkStateFlow.map { it.chain.isCustomNetwork }
val customNodes: Flow<List<NetworkConnectionRvItem>> = chainNetworkStateFlow.map { chainNetworkState ->
buildList {
val nodes = chainNetworkState.nodeHealthStates.filter { it.node.isCustom }
.map { mapNodeToUi(it, chainNetworkState) }
add(NetworkNodesAddCustomRvItem())
addAll(nodes)
}
}
val defaultNodes: Flow<List<NetworkConnectionRvItem>> = chainNetworkStateFlow.map { chainNetworkState ->
chainNetworkState.nodeHealthStates.filter { !it.node.isCustom }
.map { mapNodeToUi(it, chainNetworkState) }
}
val confirmAccountDeletion = actionAwaitableMixinFactory.confirmingOrDenyingAction<Pair<String, String>>()
val listSelectorMixin = listSelectorMixinFactory.create(viewModelScope)
fun backClicked() {
router.back()
}
fun chainEnableClicked() {
launch {
networkManagementChainInteractor.toggleChainEnableState(payload.chainId)
}
}
fun autoBalanceClicked() {
launch {
networkManagementChainInteractor.toggleAutoBalance(payload.chainId)
}
}
fun selectNode(item: NetworkNodeRvItem) {
launch {
networkManagementChainInteractor.selectNode(payload.chainId, item.unformattedUrl)
}
}
fun nodeActionClicked(item: NetworkNodeRvItem) {
launch {
val state = chainNetworkStateFlow.first()
val nodes = state.chain.nodes.nodes
if (nodes.size > 1) {
listSelectorMixin.showSelector(
R.string.manage_node_actions_title,
subtitle = item.name,
listOf(
editItem(R.string.manage_node_action_edit) { editNode(item.unformattedUrl) },
deleteItem(R.string.manage_node_action_delete) { deleteNode(item) }
)
)
} else {
editNode(item.unformattedUrl)
}
}
}
fun addNewNode() {
router.openCustomNode(CustomNodePayload(payload.chainId, CustomNodePayload.Mode.Add))
}
fun networkActionsClicked() {
listSelectorMixin.showSelector(
R.string.manage_network_actions_title,
listOf(
editItem(R.string.manage_network_action_edit, ::editNetwork),
deleteItem(R.string.manage_network_action_delete, ::deleteNetwork)
)
)
}
private fun editNetwork() {
router.openEditNetwork(AddNetworkPayload.Mode.Edit(payload.chainId))
}
private fun deleteNetwork() {
launch {
confirmAccountDeletion.awaitAction(
Pair(
resourceManager.getString(R.string.manage_network_delete_title),
resourceManager.getString(R.string.manage_network_delete_message)
)
)
networkManagementChainInteractor.deleteNetwork(payload.chainId)
router.back()
}
}
private fun editNode(nodeUrl: String) {
router.openCustomNode(CustomNodePayload(payload.chainId, CustomNodePayload.Mode.Edit(nodeUrl)))
}
private fun deleteNode(item: NetworkNodeRvItem) {
launch {
confirmAccountDeletion.awaitAction(
Pair(
resourceManager.getString(R.string.manage_network_delete_title),
resourceManager.getString(R.string.manage_network_delete_message, item.name)
)
)
networkManagementChainInteractor.deleteNode(chainId = payload.chainId, unformattedNodeUrl = item.unformattedUrl)
}
}
private fun editItem(textRes: Int, action: () -> Unit): ListSelectorMixin.Item {
return ListSelectorMixin.Item(
R.drawable.ic_pencil_edit,
R.color.icon_primary,
textRes,
R.color.text_primary,
action
)
}
private fun deleteItem(textRes: Int, action: () -> Unit): ListSelectorMixin.Item {
return ListSelectorMixin.Item(
R.drawable.ic_pencil_edit,
R.color.icon_negative,
textRes,
R.color.text_negative,
action
)
}
private fun mapNodeToUi(nodeHealthState: NodeHealthState, networkState: ChainNetworkState): NetworkNodeRvItem {
val selectingAvailable = !networkState.chain.autoBalanceEnabled && networkState.chain.isEnabled
return NetworkNodeRvItem(
id = nodeHealthState.node.unformattedUrl,
name = nodeHealthState.node.name,
unformattedUrl = nodeHealthState.node.unformattedUrl,
isEditable = nodeHealthState.node.isCustom,
isDeletable = networkState.nodeHealthStates.size > 1 && nodeHealthState.node.isCustom,
isSelected = nodeHealthState.node.unformattedUrl == networkState.connectingNode?.unformattedUrl,
connectionState = mapConnectionStateToUi(nodeHealthState),
isSelectable = selectingAvailable,
nameColorRes = if (selectingAvailable) R.color.text_primary else R.color.text_secondary
)
}
private fun mapConnectionStateToUi(nodeHealthState: NodeHealthState): ConnectionStateModel {
return when (val state = nodeHealthState.state) {
NodeHealthState.State.Connecting -> ConnectionStateModel(
name = resourceManager.getString(R.string.common_connecting),
chainStatusColor = resourceManager.getColor(R.color.text_secondary),
chainStatusIcon = R.drawable.ic_connection_status_connecting,
chainStatusIconColor = resourceManager.getColor(R.color.icon_secondary),
showShimmering = true
)
is NodeHealthState.State.Connected -> {
val (iconRes, textColorRes) = when {
state.ms < 99 -> R.drawable.ic_connection_status_good to R.color.text_positive
state.ms < 499 -> R.drawable.ic_connection_status_average to R.color.text_warning
else -> R.drawable.ic_connection_status_bad to R.color.text_negative
}
ConnectionStateModel(
name = resourceManager.getString(R.string.common_connected_ms, state.ms),
chainStatusColor = resourceManager.getColor(textColorRes),
chainStatusIcon = iconRes,
chainStatusIconColor = null,
showShimmering = false
)
}
NodeHealthState.State.Disabled -> ConnectionStateModel(
name = null,
chainStatusColor = null,
chainStatusIcon = R.drawable.ic_connection_status_connecting,
chainStatusIconColor = resourceManager.getColor(R.color.icon_inactive),
showShimmering = false
)
}
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.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_settings_impl.presentation.networkManagement.chain.ChainNetworkManagementFragment
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.ChainNetworkManagementPayload
@Subcomponent(
modules = [
ChainNetworkManagementModule::class
]
)
@ScreenScope
interface ChainNetworkManagementComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: ChainNetworkManagementPayload
): ChainNetworkManagementComponent
}
fun inject(fragment: ChainNetworkManagementFragment)
}
@@ -0,0 +1,47 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.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.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementChainInteractor
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.ChainNetworkManagementPayload
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.ChainNetworkManagementViewModel
@Module(includes = [ViewModelModule::class])
class ChainNetworkManagementModule {
@Provides
@IntoMap
@ViewModelKey(ChainNetworkManagementViewModel::class)
fun provideViewModel(
router: SettingsRouter,
resourceManager: ResourceManager,
networkManagementChainInteractor: NetworkManagementChainInteractor,
actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
listSelectorMixinFactory: ListSelectorMixin.Factory,
payload: ChainNetworkManagementPayload
): ViewModel {
return ChainNetworkManagementViewModel(
router,
resourceManager,
networkManagementChainInteractor,
actionAwaitableMixinFactory,
listSelectorMixinFactory,
payload
)
}
@Provides
fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): ChainNetworkManagementViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(ChainNetworkManagementViewModel::class.java)
}
}
@@ -0,0 +1,86 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.headerAdapter
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import io.novafoundation.nova.common.list.SingleItemAdapter
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon
import io.novafoundation.nova.feature_settings_impl.databinding.ItemChanNetworkManagementHeaderBinding
class ChainNetworkManagementHeaderAdapter(
private val imageLoader: ImageLoader,
private val itemHandler: ItemHandler
) : SingleItemAdapter<ChainNetworkManagementHeaderViewHolder>(isShownByDefault = true) {
private var chainUiModel: ChainUi? = null
private var chainEnabled = false
private var autoBalanceEnabled = false
private var networkCanBeDisabled = false
interface ItemHandler {
fun chainEnableClicked()
fun autoBalanceClicked()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChainNetworkManagementHeaderViewHolder {
return ChainNetworkManagementHeaderViewHolder(
ItemChanNetworkManagementHeaderBinding.inflate(parent.inflater(), parent, false),
imageLoader,
itemHandler
)
}
override fun onBindViewHolder(holder: ChainNetworkManagementHeaderViewHolder, position: Int) {
chainUiModel?.let { holder.bind(it, chainEnabled, autoBalanceEnabled, networkCanBeDisabled) }
}
fun setChainUiModel(chainUiModel: ChainUi) {
this.chainUiModel = chainUiModel
notifyItemChanged(0)
}
fun setChainEnabled(chainEnabled: Boolean) {
this.chainEnabled = chainEnabled
notifyItemChanged(0)
}
fun setAutoBalanceEnabled(autoBalanceEnabled: Boolean) {
this.autoBalanceEnabled = autoBalanceEnabled
notifyItemChanged(0)
}
fun setNetworkCanBeDisabled(networkCanBeDisabled: Boolean) {
this.networkCanBeDisabled = networkCanBeDisabled
}
}
class ChainNetworkManagementHeaderViewHolder(
private val binder: ItemChanNetworkManagementHeaderBinding,
private val imageLoader: ImageLoader,
private val itemHandler: ChainNetworkManagementHeaderAdapter.ItemHandler
) : RecyclerView.ViewHolder(binder.root) {
init {
with(binder) {
chainNetworkManagementEnable.setOnClickListener { itemHandler.chainEnableClicked() }
chainNetworkManagementAutoBalance.setOnClickListener { itemHandler.autoBalanceClicked() }
}
}
fun bind(chainUi: ChainUi, chainEnabled: Boolean, autoBalanceEnabled: Boolean, networkCanBeDisabled: Boolean) {
with(binder) {
chainNetworkManagementIcon.loadChainIcon(chainUi.icon, imageLoader)
chainNetworkManagementTitle.text = chainUi.name
chainNetworkManagementEnable.setChecked(chainEnabled)
chainNetworkManagementEnable.isEnabled = networkCanBeDisabled
chainNetworkManagementAutoBalance.setChecked(autoBalanceEnabled)
chainNetworkManagementAutoBalance.isEnabled = chainEnabled
}
}
}
@@ -0,0 +1,129 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter
import android.annotation.SuppressLint
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.setCompoundDrawableTint
import io.novafoundation.nova.common.utils.setDrawableStart
import io.novafoundation.nova.common.utils.setShimmerShown
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.common.view.shape.getMaskedRipple
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.databinding.ItemChanNetworkManagementAddNodeButtonBinding
import io.novafoundation.nova.feature_settings_impl.databinding.ItemChanNetworkManagementNodeBinding
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items.NetworkConnectionRvItem
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items.NetworkNodeRvItem
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items.NetworkNodesAddCustomRvItem
class ChainNetworkManagementNodesAdapter(
private val itemHandler: ItemHandler
) : ListAdapter<NetworkConnectionRvItem, ViewHolder>(DiffCallback()) {
interface ItemHandler {
fun selectNode(item: NetworkNodeRvItem)
fun editNode(item: NetworkNodeRvItem)
fun addNewNode()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return when (viewType) {
R.layout.item_chan_network_management_node -> ChainNetworkManagementNodeViewHolder(
ItemChanNetworkManagementNodeBinding.inflate(parent.inflater(), parent, false),
itemHandler
)
R.layout.item_chan_network_management_add_node_button -> ChainNetworkManagementAddNodeButtonViewHolder(
ItemChanNetworkManagementAddNodeButtonBinding.inflate(parent.inflater(), parent, false),
itemHandler
)
else -> throw IllegalArgumentException("Unknown view type")
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
if (holder is ChainNetworkManagementNodeViewHolder) {
holder.bind(getItem(position) as NetworkNodeRvItem)
}
}
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is NetworkNodeRvItem -> R.layout.item_chan_network_management_node
is NetworkNodesAddCustomRvItem -> R.layout.item_chan_network_management_add_node_button
else -> throw IllegalArgumentException("Unknown item type")
}
}
}
class ChainNetworkManagementAddNodeButtonViewHolder(
private val binder: ItemChanNetworkManagementAddNodeButtonBinding,
private val itemHandler: ChainNetworkManagementNodesAdapter.ItemHandler
) : ViewHolder(binder.root) {
init {
itemView.background = itemView.context.getMaskedRipple(cornerSizeInDp = 0)
itemView.setOnClickListener {
itemHandler.addNewNode()
}
}
}
class ChainNetworkManagementNodeViewHolder(
private val binder: ItemChanNetworkManagementNodeBinding,
private val itemHandler: ChainNetworkManagementNodesAdapter.ItemHandler
) : ViewHolder(binder.root) {
init {
itemView.background = itemView.context.getMaskedRipple(cornerSizeInDp = 0)
}
fun bind(item: NetworkNodeRvItem) {
with(binder) {
if (item.isSelectable) {
itemView.setOnClickListener { itemHandler.selectNode(item) }
} else {
itemView.setOnClickListener(null)
}
chainNodeRadioButton.isChecked = item.isSelected
chainNodeRadioButton.isEnabled = item.isSelectable
chainNodeName.text = item.name
chainNodeName.setTextColorRes(item.nameColorRes)
chainNodeSocketAddress.text = item.unformattedUrl
chainNodeConnectionStatusShimmering.setShimmerShown(item.connectionState.showShimmering)
chainNodeConnectionState.setText(item.connectionState.name)
item.connectionState.chainStatusColor?.let { chainNodeConnectionState.setTextColor(it) }
chainNodeConnectionState.setDrawableStart(item.connectionState.chainStatusIcon, paddingInDp = 6)
chainNodeConnectionState.setCompoundDrawableTint(item.connectionState.chainStatusIconColor)
chainNodeEditButton.isVisible = item.isEditable && !item.isDeletable
chainNodeManageButton.isVisible = item.isEditable && item.isDeletable
chainNodeEditButton.setOnClickListener { itemHandler.editNode(item) }
chainNodeManageButton.setOnClickListener { itemHandler.editNode(item) }
}
}
}
private class DiffCallback : DiffUtil.ItemCallback<NetworkConnectionRvItem>() {
override fun areItemsTheSame(oldItem: NetworkConnectionRvItem, newItem: NetworkConnectionRvItem): Boolean {
return when (oldItem) {
is NetworkNodeRvItem -> newItem is NetworkNodeRvItem && oldItem.id == newItem.id
is NetworkNodesAddCustomRvItem -> newItem is NetworkNodesAddCustomRvItem
else -> throw IllegalArgumentException("Unknown item type")
}
}
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: NetworkConnectionRvItem, newItem: NetworkConnectionRvItem): Boolean {
return oldItem == newItem
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import io.novafoundation.nova.common.list.decoration.BackgroundItemDecoration
import io.novafoundation.nova.common.list.decoration.DividerItemDecoration
class NodesItemBackgroundDecoration(context: Context) : BackgroundItemDecoration(
context = context,
outerHorizontalMarginDp = 16,
innerVerticalPaddingDp = 0
) {
override fun shouldApplyDecoration(holder: RecyclerView.ViewHolder): Boolean {
return holder.isNodeItem()
}
}
class NodesItemDividerDecoration(context: Context) : DividerItemDecoration(context, dividerMarginDp = 32) {
override fun shouldApplyDecorationBetween(top: RecyclerView.ViewHolder, bottom: RecyclerView.ViewHolder): Boolean {
return top.isNodeItem() && bottom.isNodeItem()
}
}
private fun RecyclerView.ViewHolder.isNodeItem(): Boolean {
return this is ChainNetworkManagementNodeViewHolder || this is ChainNetworkManagementAddNodeButtonViewHolder
}
@@ -0,0 +1,3 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items
interface NetworkConnectionRvItem
@@ -0,0 +1,16 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items
import androidx.annotation.ColorRes
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.common.ConnectionStateModel
data class NetworkNodeRvItem(
val id: String,
val name: String,
@ColorRes val nameColorRes: Int,
val unformattedUrl: String,
val isEditable: Boolean,
val isDeletable: Boolean,
val isSelected: Boolean,
val connectionState: ConnectionStateModel,
val isSelectable: Boolean
) : NetworkConnectionRvItem
@@ -0,0 +1,3 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items
class NetworkNodesAddCustomRvItem : NetworkConnectionRvItem
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.common
data class ConnectionStateModel(
val name: String?,
val chainStatusColor: Int?,
val chainStatusIcon: Int,
val chainStatusIconColor: Int?,
val showShimmering: Boolean
)
@@ -0,0 +1,60 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.setupWithViewPager2
import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi
import io.novafoundation.nova.feature_settings_impl.databinding.FragmentNetworkManagementBinding
import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent
class NetworkManagementListFragment : BaseFragment<NetworkManagementListViewModel, FragmentNetworkManagementBinding>() {
companion object {
private const val KEY_OPEN_ADDED_TAB = "key_payload"
fun getBundle(openAddedTab: Boolean): Bundle {
return Bundle().apply {
putBoolean(KEY_OPEN_ADDED_TAB, openAddedTab)
}
}
}
override fun createBinding() = FragmentNetworkManagementBinding.inflate(layoutInflater)
override fun initViews() {
binder.networkManagementToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.networkManagementToolbar.setRightActionClickListener { viewModel.addNetworkClicked() }
val adapter = NetworkManagementPagerAdapter(this)
binder.networkManagementViewPager.adapter = adapter
binder.networkManagementTabLayout.setupWithViewPager2(binder.networkManagementViewPager, adapter::getPageTitle)
Handler(Looper.getMainLooper()).post {
setDefaultTab(adapter)
}
}
private fun setDefaultTab(adapter: NetworkManagementPagerAdapter) {
val openAddedTab = argumentOrNull<Boolean>(KEY_OPEN_ADDED_TAB) ?: return
val tabIndex = if (openAddedTab) adapter.addedTabIndex() else adapter.defaultTabIndex()
binder.networkManagementViewPager.currentItem = tabIndex
}
override fun inject() {
FeatureUtils.getFeature<SettingsFeatureComponent>(
requireContext(),
SettingsFeatureApi::class.java
)
.networkManagementListFactory()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: NetworkManagementListViewModel) {
}
}
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
class NetworkManagementListViewModel(
private val router: SettingsRouter
) : BaseViewModel() {
fun backClicked() {
router.back()
}
fun addNetworkClicked() {
router.addNetwork()
}
}
@@ -0,0 +1,38 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.AddedNetworkListFragment
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks.ExistingNetworkListFragment
class NetworkManagementPagerAdapter(private val fragment: Fragment) : FragmentStateAdapter(fragment) {
override fun getItemCount(): Int {
return 2
}
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> ExistingNetworkListFragment()
1 -> AddedNetworkListFragment()
else -> throw IllegalArgumentException("Invalid position")
}
}
fun getPageTitle(position: Int): CharSequence {
return when (position) {
defaultTabIndex() -> fragment.getString(R.string.network_management_default_page_title)
addedTabIndex() -> fragment.getString(R.string.network_management_added_page_title)
else -> throw IllegalArgumentException("Invalid position")
}
}
fun defaultTabIndex(): Int {
return 0
}
fun addedTabIndex(): Int {
return 1
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main.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_settings_impl.presentation.networkManagement.main.NetworkManagementListFragment
@Subcomponent(
modules = [
NetworkManagementListModule::class
]
)
@ScreenScope
interface NetworkManagementListComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
): NetworkManagementListComponent
}
fun inject(fragment: NetworkManagementListFragment)
}
@@ -0,0 +1,32 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main.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.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main.NetworkManagementListViewModel
@Module(includes = [ViewModelModule::class])
class NetworkManagementListModule {
@Provides
@IntoMap
@ViewModelKey(NetworkManagementListViewModel::class)
fun provideViewModel(
router: SettingsRouter
): ViewModel {
return NetworkManagementListViewModel(
router
)
}
@Provides
fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): NetworkManagementListViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(NetworkManagementListViewModel::class.java)
}
}
@@ -0,0 +1,68 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.RecyclerView
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.list.EditablePlaceholderAdapter
import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents
import io.novafoundation.nova.common.utils.ViewSpace
import io.novafoundation.nova.common.view.PlaceholderModel
import io.novafoundation.nova.common.view.PlaceholderView
import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.adapter.NetworksBannerAdapter
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListFragment
class AddedNetworkListFragment : NetworkListFragment<AddedNetworkListViewModel>(), NetworksBannerAdapter.ItemHandler {
private val bannerAdapter = NetworksBannerAdapter(this)
private val placeholderAdapter = EditablePlaceholderAdapter()
override val adapter: RecyclerView.Adapter<*> by lazy(LazyThreadSafetyMode.NONE) {
ConcatAdapter(bannerAdapter, placeholderAdapter, networksAdapter)
}
override fun inject() {
FeatureUtils.getFeature<SettingsFeatureComponent>(
requireContext(),
SettingsFeatureApi::class.java
)
.addedNetworkListFactory()
.create(this)
.inject(this)
}
override fun initViews() {
super.initViews()
placeholderAdapter.setPadding(ViewSpace(top = 64.dp))
placeholderAdapter.setPlaceholderData(
PlaceholderModel(
requireContext().getString(R.string.added_networks_empty_placeholder),
R.drawable.ic_no_added_networks,
requireContext().getString(R.string.common_add_network),
PlaceholderView.Style.NO_BACKGROUND,
imageTint = null
)
)
placeholderAdapter.setButtonClickListener { viewModel.addNetworkClicked() }
}
override fun subscribe(viewModel: AddedNetworkListViewModel) {
super.subscribe(viewModel)
observeBrowserEvents(viewModel)
viewModel.showBanner.observe { bannerAdapter.show(it) }
viewModel.networkList.observe { placeholderAdapter.show(it.isEmpty()) }
}
override fun closeBannerClicked() {
viewModel.closeBannerClicked()
}
override fun bannerWikiLinkClicked() {
viewModel.bannerWikiClicked()
}
}
@@ -0,0 +1,49 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks
import androidx.lifecycle.MutableLiveData
import io.novafoundation.nova.common.data.network.AppLinksProvider
import io.novafoundation.nova.common.mixin.api.Browserable
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.event
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementInteractor
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListAdapterItemFactory
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListViewModel
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter.NetworkListRvItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
class AddedNetworkListViewModel(
private val networkManagementInteractor: NetworkManagementInteractor,
private val networkListAdapterItemFactory: NetworkListAdapterItemFactory,
private val appLinksProvider: AppLinksProvider,
private val router: SettingsRouter
) : NetworkListViewModel(router), Browserable {
private val networks = networkManagementInteractor.addedNetworksFlow()
.shareInBackground()
override val networkList: Flow<List<NetworkListRvItem>> = networks.mapList {
networkListAdapterItemFactory.getNetworkItem(it)
}
override val openBrowserEvent = MutableLiveData<Event<String>>()
val showBanner = networkManagementInteractor.shouldShowBanner()
.shareInBackground()
fun closeBannerClicked() {
launch {
networkManagementInteractor.hideBanner()
}
}
fun bannerWikiClicked() {
openBrowserEvent.value = appLinksProvider.integrateNetwork.event()
}
fun addNetworkClicked() {
router.addNetwork()
}
}
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.adapter
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.novafoundation.nova.common.list.SingleItemAdapter
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.feature_settings_impl.databinding.ItemIntegrateNetworksBannerBinding
class NetworksBannerAdapter(
private val itemHandler: ItemHandler
) : SingleItemAdapter<NetworkBannerViewHolder>() {
interface ItemHandler {
fun closeBannerClicked()
fun bannerWikiLinkClicked()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NetworkBannerViewHolder {
return NetworkBannerViewHolder(ItemIntegrateNetworksBannerBinding.inflate(parent.inflater(), parent, false), itemHandler)
}
override fun onBindViewHolder(holder: NetworkBannerViewHolder, position: Int) {
// Not need to bind anything
}
}
class NetworkBannerViewHolder(
private val binder: ItemIntegrateNetworksBannerBinding,
private val itemHandler: NetworksBannerAdapter.ItemHandler
) : RecyclerView.ViewHolder(binder.root) {
init {
with(binder) {
integrateNetworkBannerClose.setOnClickListener { itemHandler.closeBannerClicked() }
integrateNetworkBannerLink.setOnClickListener { itemHandler.bannerWikiLinkClicked() }
}
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.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_settings_impl.presentation.networkManagement.networkList.addedNetworks.AddedNetworkListFragment
@Subcomponent(
modules = [
AddedNetworkListModule::class
]
)
@ScreenScope
interface AddedNetworkListComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
): AddedNetworkListComponent
}
fun inject(fragment: AddedNetworkListFragment)
}
@@ -0,0 +1,41 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.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.data.network.AppLinksProvider
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementInteractor
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListAdapterItemFactory
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.AddedNetworkListViewModel
@Module(includes = [ViewModelModule::class])
class AddedNetworkListModule {
@Provides
@IntoMap
@ViewModelKey(AddedNetworkListViewModel::class)
fun provideViewModel(
networkManagementInteractor: NetworkManagementInteractor,
networkListAdapterItemFactory: NetworkListAdapterItemFactory,
appLinksProvider: AppLinksProvider,
router: SettingsRouter
): ViewModel {
return AddedNetworkListViewModel(
networkManagementInteractor,
networkListAdapterItemFactory,
appLinksProvider,
router
)
}
@Provides
fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): AddedNetworkListViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(AddedNetworkListViewModel::class.java)
}
}
@@ -0,0 +1,76 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_api.presenatation.chain.asIconOrFallback
import io.novafoundation.nova.feature_account_api.presenatation.chain.iconOrFallback
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.domain.NetworkState
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.common.ConnectionStateModel
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter.NetworkListRvItem
import io.novafoundation.nova.runtime.ext.isDisabled
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.LightChain
import io.novasama.substrate_sdk_android.wsrpc.state.SocketStateMachine
interface NetworkListAdapterItemFactory {
fun getNetworkItem(network: NetworkState): NetworkListRvItem
fun getNetworkItem(network: LightChain): NetworkListRvItem
}
class RealNetworkListAdapterItemFactory(
private val resourceManager: ResourceManager
) : NetworkListAdapterItemFactory {
override fun getNetworkItem(network: NetworkState): NetworkListRvItem {
val chain = network.chain
val subtitle = if (chain.isDisabled) resourceManager.getString(R.string.common_disabled) else null
val label = getChainLabel(chain)
return NetworkListRvItem(
chainIcon = chain.iconOrFallback(),
chainId = chain.id,
title = chain.name,
subtitle = subtitle,
chainLabel = label,
disabled = chain.isDisabled,
status = getConnectingState(network)
)
}
override fun getNetworkItem(network: LightChain): NetworkListRvItem {
return NetworkListRvItem(
chainIcon = network.icon.asIconOrFallback(),
chainId = network.id,
title = network.name,
subtitle = null,
chainLabel = null,
disabled = false,
status = null
)
}
private fun getChainLabel(chain: Chain): String? {
return if (chain.isTestNet) {
resourceManager.getString(R.string.common_testnet)
} else {
null
}
}
private fun getConnectingState(network: NetworkState): ConnectionStateModel? {
if (network.chain.isDisabled) return null
return when (network.connectionState) {
is SocketStateMachine.State.Connected -> null
else -> ConnectionStateModel(
name = resourceManager.getString(R.string.common_connecting),
chainStatusColor = resourceManager.getColor(R.color.text_primary),
chainStatusIcon = R.drawable.ic_connection_status_connecting,
chainStatusIconColor = resourceManager.getColor(R.color.icon_primary),
showShimmering = true
)
}
}
}
@@ -0,0 +1,34 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.feature_settings_impl.databinding.FragmentNetworkListBinding
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter.NetworkManagementListAdapter
import javax.inject.Inject
abstract class NetworkListFragment<T : NetworkListViewModel> : BaseFragment<T, FragmentNetworkListBinding>(), NetworkManagementListAdapter.ItemHandler {
override fun createBinding() = FragmentNetworkListBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
protected val networksAdapter by lazy(LazyThreadSafetyMode.NONE) { NetworkManagementListAdapter(imageLoader, this) }
protected abstract val adapter: RecyclerView.Adapter<*>
override fun initViews() {
binder.networkList.adapter = adapter
binder.networkList.itemAnimator = null
}
override fun subscribe(viewModel: T) {
viewModel.networkList.observe { networksAdapter.submitList(it) }
}
override fun onNetworkClicked(chainId: String) {
viewModel.onNetworkClicked(chainId)
}
}
@@ -0,0 +1,18 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.ChainNetworkManagementPayload
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter.NetworkListRvItem
import kotlinx.coroutines.flow.Flow
abstract class NetworkListViewModel(
private val router: SettingsRouter
) : BaseViewModel() {
abstract val networkList: Flow<List<NetworkListRvItem>>
fun onNetworkClicked(chainId: String) {
router.openNetworkDetails(ChainNetworkManagementPayload(chainId))
}
}
@@ -0,0 +1,15 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter
import io.novafoundation.nova.common.utils.images.Icon
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.common.ConnectionStateModel
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
data class NetworkListRvItem(
val chainIcon: Icon,
val chainId: ChainId,
val title: String,
val subtitle: String?,
val chainLabel: String?,
val disabled: Boolean,
val status: ConnectionStateModel?
)
@@ -0,0 +1,85 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter
import android.annotation.SuppressLint
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import coil.ImageLoader
import io.novafoundation.nova.common.utils.images.setIcon
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.setCompoundDrawableTint
import io.novafoundation.nova.common.utils.setDrawableStart
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.common.utils.setTextOrHide
import io.novafoundation.nova.common.view.shape.getMaskedRipple
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.databinding.ItemNetworkSettingsBinding
class NetworkManagementListAdapter(
private val imageLoader: ImageLoader,
private val itemHandler: ItemHandler
) : ListAdapter<NetworkListRvItem, NetworkListViewHolder>(NetworkManagementListDiffCallback()) {
interface ItemHandler {
fun onNetworkClicked(chainId: String)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NetworkListViewHolder {
return NetworkListViewHolder(ItemNetworkSettingsBinding.inflate(parent.inflater(), parent, false), imageLoader, itemHandler)
}
override fun onBindViewHolder(holder: NetworkListViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
class NetworkManagementListDiffCallback : DiffUtil.ItemCallback<NetworkListRvItem>() {
override fun areItemsTheSame(oldItem: NetworkListRvItem, newItem: NetworkListRvItem): Boolean {
return oldItem.chainId == newItem.chainId
}
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: NetworkListRvItem, newItem: NetworkListRvItem): Boolean {
return oldItem == newItem
}
}
class NetworkListViewHolder(
private val binder: ItemNetworkSettingsBinding,
private val imageLoader: ImageLoader,
private val itemHandler: NetworkManagementListAdapter.ItemHandler
) : ViewHolder(binder.root) {
init {
itemView.background = itemView.context.getMaskedRipple(cornerSizeInDp = 0)
}
fun bind(item: NetworkListRvItem) = with(binder) {
itemView.setOnClickListener { itemHandler.onNetworkClicked(item.chainId) }
itemNetworkImage.setIcon(item.chainIcon, imageLoader)
itemNetworkTitle.text = item.title
itemNetworkSubtitle.setTextOrHide(item.subtitle)
itemNetworkLabel.setTextOrHide(item.chainLabel)
itemNetworkStatusShimmer.isVisible = item.status != null
if (item.status != null) {
itemNetworkStatus.setText(item.status.name)
item.status.chainStatusColor?.let { itemNetworkStatus.setTextColor(it) }
itemNetworkStatus.setDrawableStart(item.status.chainStatusIcon, paddingInDp = 6)
itemNetworkStatus.setCompoundDrawableTint(item.status.chainStatusIconColor)
}
if (item.disabled) {
itemNetworkImage.alpha = 0.32f
itemNetworkTitle.setTextColorRes(R.color.text_secondary)
} else {
itemNetworkImage.alpha = 1f
itemNetworkTitle.setTextColorRes(R.color.text_primary)
}
}
}
@@ -0,0 +1,22 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks
import androidx.recyclerview.widget.RecyclerView
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi
import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListFragment
class ExistingNetworkListFragment : NetworkListFragment<ExistingNetworkListViewModel>() {
override val adapter: RecyclerView.Adapter<*> by lazy(LazyThreadSafetyMode.NONE) { networksAdapter }
override fun inject() {
FeatureUtils.getFeature<SettingsFeatureComponent>(
requireContext(),
SettingsFeatureApi::class.java
)
.existingNetworkListFactory()
.create(this)
.inject(this)
}
}
@@ -0,0 +1,23 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementInteractor
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListAdapterItemFactory
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListViewModel
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter.NetworkListRvItem
import kotlinx.coroutines.flow.Flow
class ExistingNetworkListViewModel(
private val networkManagementInteractor: NetworkManagementInteractor,
private val networkListAdapterItemFactory: NetworkListAdapterItemFactory,
router: SettingsRouter
) : NetworkListViewModel(router) {
private val networks = networkManagementInteractor.defaultNetworksFlow()
.shareInBackground()
override val networkList: Flow<List<NetworkListRvItem>> = networks.mapList {
networkListAdapterItemFactory.getNetworkItem(it)
}.shareInBackground()
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks.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_settings_impl.presentation.networkManagement.networkList.defaultNetworks.ExistingNetworkListFragment
@Subcomponent(
modules = [
ExistingNetworkListModule::class
]
)
@ScreenScope
interface ExistingNetworkListComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
): ExistingNetworkListComponent
}
fun inject(fragment: ExistingNetworkListFragment)
}
@@ -0,0 +1,38 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks.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.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementInteractor
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListAdapterItemFactory
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks.ExistingNetworkListViewModel
@Module(includes = [ViewModelModule::class])
class ExistingNetworkListModule {
@Provides
@IntoMap
@ViewModelKey(ExistingNetworkListViewModel::class)
fun provideViewModel(
networkManagementInteractor: NetworkManagementInteractor,
networkListAdapterItemFactory: NetworkListAdapterItemFactory,
router: SettingsRouter
): ViewModel {
return ExistingNetworkListViewModel(
networkManagementInteractor,
networkListAdapterItemFactory,
router
)
}
@Provides
fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): ExistingNetworkListViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(ExistingNetworkListViewModel::class.java)
}
}
@@ -0,0 +1,73 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks
import androidx.core.view.isVisible
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.ConcatAdapter
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.domain.ExtendedLoadingState
import io.novafoundation.nova.common.domain.isLoading
import io.novafoundation.nova.common.mixin.impl.observeRetries
import io.novafoundation.nova.common.utils.bindTo
import io.novafoundation.nova.common.utils.progress.observeProgressDialog
import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi
import io.novafoundation.nova.feature_settings_impl.databinding.FragmentPreConfiguredNetworkListBinding
import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter.NetworkManagementListAdapter
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks.adapter.AddCustomNetworkAdapter
import javax.inject.Inject
class PreConfiguredNetworksFragment :
BaseFragment<PreConfiguredNetworksViewModel, FragmentPreConfiguredNetworkListBinding>(),
NetworkManagementListAdapter.ItemHandler,
AddCustomNetworkAdapter.ItemHandler {
override fun createBinding() = FragmentPreConfiguredNetworkListBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
private val addCustomNetworkAdapter = AddCustomNetworkAdapter(this)
private val networksAdapter by lazy(LazyThreadSafetyMode.NONE) { NetworkManagementListAdapter(imageLoader, this) }
private val adapter by lazy(LazyThreadSafetyMode.NONE) { ConcatAdapter(addCustomNetworkAdapter, networksAdapter) }
override fun initViews() {
binder.preConfiguredNetworksToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.preConfiguredNetworkList.adapter = adapter
}
override fun inject() {
FeatureUtils.getFeature<SettingsFeatureComponent>(
requireContext(),
SettingsFeatureApi::class.java
)
.preConfiguredNetworks()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: PreConfiguredNetworksViewModel) {
observeRetries(viewModel)
observeProgressDialog(viewModel.progressDialogMixin)
binder.preConfiguredNetworksSearch.content.bindTo(viewModel.searchQuery, viewModel.viewModelScope)
viewModel.networkList.observe {
binder.preConfiguredNetworkProgress.isVisible = it.isLoading()
if (it is ExtendedLoadingState.Loaded) {
networksAdapter.submitList(it.data)
}
}
}
override fun onNetworkClicked(chainId: String) {
viewModel.networkClicked(chainId)
}
override fun onAddNetworkClicked() {
viewModel.addNetworkClicked()
}
}
@@ -0,0 +1,123 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks
import androidx.lifecycle.MutableLiveData
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.domain.ExtendedLoadingState
import io.novafoundation.nova.common.domain.map
import io.novafoundation.nova.common.domain.mapLoading
import io.novafoundation.nova.common.mixin.api.Retriable
import io.novafoundation.nova.common.mixin.api.RetryPayload
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory
import io.novafoundation.nova.common.utils.progress.startProgress
import io.novafoundation.nova.feature_push_notifications.R
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.PreConfiguredNetworksInteractor
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListAdapterItemFactory
import io.novafoundation.nova.runtime.ext.networkType
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.LightChain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.NetworkType
import io.novafoundation.nova.runtime.util.ChainParcel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
typealias LoadingNetworks = ExtendedLoadingState<List<LightChain>>
class PreConfiguredNetworksViewModel(
private val interactor: PreConfiguredNetworksInteractor,
private val networkListAdapterItemFactory: NetworkListAdapterItemFactory,
private val router: SettingsRouter,
private val resourceManager: ResourceManager,
private val chainRegistry: ChainRegistry,
private val progressDialogMixinFactory: ProgressDialogMixinFactory
) : BaseViewModel(), Retriable {
val progressDialogMixin = progressDialogMixinFactory.create()
val searchQuery: MutableStateFlow<String> = MutableStateFlow("")
private val allPreConfiguredNetworksFlow = MutableStateFlow<LoadingNetworks>(ExtendedLoadingState.Loading)
private val networks = combine(
allPreConfiguredNetworksFlow,
searchQuery,
chainRegistry.chainsById
) { preConfiguredNetworks, query, currentChains ->
preConfiguredNetworks.map {
val filteredNetworks = interactor.excludeChains(it, currentChains.keys)
interactor.searchNetworks(query, filteredNetworks)
}
}
val networkList = networks.mapLoading { networks ->
networks.map { networkListAdapterItemFactory.getNetworkItem(it) }
}
override val retryEvent: MutableLiveData<Event<RetryPayload>> = MutableLiveData()
init {
fetchPreConfiguredNetworks()
}
fun backClicked() {
router.back()
}
fun networkClicked(chainId: String) {
launch {
progressDialogMixin.startProgress(R.string.loading_network_info) {
interactor.getPreConfiguredNetwork(chainId)
.onSuccess {
openAddChainScreen(it)
}
.onFailure { showError(resourceManager.getString(R.string.common_something_went_wrong_title)) }
}
}
}
private fun openAddChainScreen(chain: Chain) {
val networkType = when (chain.networkType()) {
NetworkType.SUBSTRATE -> AddNetworkPayload.Mode.Add.NetworkType.SUBSTRATE
NetworkType.EVM -> AddNetworkPayload.Mode.Add.NetworkType.EVM
}
val payload = AddNetworkPayload.Mode.Add(
networkType = networkType,
ChainParcel(chain)
)
router.openCreateNetworkFlow(payload)
}
fun addNetworkClicked() {
router.openCreateNetworkFlow()
}
private fun fetchPreConfiguredNetworks() {
launch {
allPreConfiguredNetworksFlow.value = ExtendedLoadingState.Loading
interactor.getPreConfiguredNetworks()
.onSuccess {
allPreConfiguredNetworksFlow.value = ExtendedLoadingState.Loaded(it)
}.onFailure {
launchRetryDialog()
}
}
}
private fun launchRetryDialog() {
retryEvent.value = Event(
RetryPayload(
title = resourceManager.getString(R.string.common_error_general_title),
message = resourceManager.getString(R.string.common_retry_message),
onRetry = { fetchPreConfiguredNetworks() },
onCancel = { router.back() }
)
)
}
}
@@ -0,0 +1,39 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks.adapter
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import io.novafoundation.nova.common.utils.inflateChild
import io.novafoundation.nova.common.view.shape.getMaskedRipple
import io.novafoundation.nova.feature_settings_impl.R
class AddCustomNetworkAdapter(
private val itemHandler: ItemHandler
) : RecyclerView.Adapter<AddCustomNetworkViewHolder>() {
interface ItemHandler {
fun onAddNetworkClicked()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddCustomNetworkViewHolder {
return AddCustomNetworkViewHolder(parent, itemHandler)
}
override fun getItemCount(): Int {
return 1
}
override fun onBindViewHolder(holder: AddCustomNetworkViewHolder, position: Int) {}
}
class AddCustomNetworkViewHolder(
parent: ViewGroup,
private val itemHandler: AddCustomNetworkAdapter.ItemHandler
) : ViewHolder(parent.inflateChild(R.layout.item_add_custom_network)) {
init {
itemView.background = itemView.context.getMaskedRipple(cornerSizeInDp = 0)
itemView.setOnClickListener { itemHandler.onAddNetworkClicked() }
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks.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_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks.PreConfiguredNetworksFragment
@Subcomponent(
modules = [
PreConfiguredNetworksModule::class
]
)
@ScreenScope
interface PreConfiguredNetworksComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
): PreConfiguredNetworksComponent
}
fun inject(fragment: PreConfiguredNetworksFragment)
}
@@ -0,0 +1,49 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks.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.data.network.coingecko.CoinGeckoLinkParser
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.common.utils.progress.ProgressDialogMixinFactory
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.PreConfiguredNetworksInteractor
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListAdapterItemFactory
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks.PreConfiguredNetworksViewModel
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class PreConfiguredNetworksModule {
@Provides
@IntoMap
@ViewModelKey(PreConfiguredNetworksViewModel::class)
fun provideViewModel(
preConfiguredNetworksInteractor: PreConfiguredNetworksInteractor,
networkListAdapterItemFactory: NetworkListAdapterItemFactory,
router: SettingsRouter,
resourceManager: ResourceManager,
chainRegistry: ChainRegistry,
progressDialogMixinFactory: ProgressDialogMixinFactory,
coinGeckoLinkParser: CoinGeckoLinkParser
): ViewModel {
return PreConfiguredNetworksViewModel(
interactor = preConfiguredNetworksInteractor,
networkListAdapterItemFactory = networkListAdapterItemFactory,
router = router,
resourceManager = resourceManager,
chainRegistry = chainRegistry,
progressDialogMixinFactory = progressDialogMixinFactory
)
}
@Provides
fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): PreConfiguredNetworksViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(PreConfiguredNetworksViewModel::class.java)
}
}
@@ -0,0 +1,65 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node
import android.os.Bundle
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.mixin.impl.observeValidations
import io.novafoundation.nova.common.utils.bindTo
import io.novafoundation.nova.common.view.setState
import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi
import io.novafoundation.nova.feature_settings_impl.databinding.FragmentCustomNodeBinding
import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent
import javax.inject.Inject
class CustomNodeFragment : BaseFragment<CustomNodeViewModel, FragmentCustomNodeBinding>() {
companion object {
private const val EXTRA_PAYLOAD = "payload"
fun getBundle(payload: CustomNodePayload): Bundle {
return Bundle().apply {
putParcelable(EXTRA_PAYLOAD, payload)
}
}
}
override fun createBinding() = FragmentCustomNodeBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
override fun initViews() {
binder.customNodeToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.customNodeApplyButton.prepareForProgress(this)
binder.customNodeApplyButton.setOnClickListener { viewModel.saveNodeClicked() }
}
override fun inject() {
FeatureUtils.getFeature<SettingsFeatureComponent>(
requireContext(),
SettingsFeatureApi::class.java
)
.customNodeFactory()
.create(this, argument(EXTRA_PAYLOAD))
.inject(this)
}
override fun subscribe(viewModel: CustomNodeViewModel) {
observeValidations(viewModel)
binder.customNodeTitle.text = viewModel.getTitle()
binder.customNodeUrlInput.bindTo(viewModel.nodeUrlInput, viewModel)
binder.customNodeNameInput.bindTo(viewModel.nodeNameInput, viewModel)
viewModel.buttonState.observe {
binder.customNodeApplyButton.setState(it)
}
viewModel.chainModel.observe {
binder.customNodeChain.setChain(it)
}
}
}
@@ -0,0 +1,20 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class CustomNodePayload(
val chainId: String,
val mode: Mode
) : Parcelable {
sealed interface Mode : Parcelable {
@Parcelize
object Add : Mode
@Parcelize
class Edit(val url: String) : Mode
}
}
@@ -0,0 +1,120 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.mixin.api.Validatable
import io.novafoundation.nova.common.presentation.DescriptiveButtonState
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.validation.progressConsumer
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.CustomNodeInteractor
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.NetworkNodePayload
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class CustomNodeViewModel(
private val router: SettingsRouter,
private val resourceManager: ResourceManager,
private val payload: CustomNodePayload,
private val customNodeInteractor: CustomNodeInteractor,
private val validationExecutor: ValidationExecutor,
private val chainRegistry: ChainRegistry
) : BaseViewModel(), Validatable by validationExecutor {
val chainModel = flowOf { chainRegistry.getChain(payload.chainId) }
.map { mapChainToUi(it) }
val nodeUrlInput = MutableStateFlow("")
val nodeNameInput = MutableStateFlow("")
private val saveProgressFlow = MutableStateFlow(false)
val buttonState = combine(saveProgressFlow, nodeUrlInput, nodeNameInput) { validationProgress, url, name ->
when {
validationProgress -> DescriptiveButtonState.Loading
url.isBlank() || name.isBlank() -> {
DescriptiveButtonState.Disabled(resourceManager.getString(R.string.custom_node_disabled_button_state))
}
else -> getEnabledButtonState()
}
}
init {
launch {
if (payload.mode is CustomNodePayload.Mode.Edit) {
customNodeInteractor.getNodeDetails(payload.chainId, payload.mode.url)
.onSuccess { node ->
nodeUrlInput.value = node.unformattedUrl
nodeNameInput.value = node.name
}.onFailure {
router.back()
}
}
}
}
fun backClicked() {
router.back()
}
fun getTitle(): String {
return when (payload.mode) {
CustomNodePayload.Mode.Add -> resourceManager.getString(R.string.custom_node_add_title)
is CustomNodePayload.Mode.Edit -> resourceManager.getString(R.string.custom_node_edit_title)
}
}
fun saveNodeClicked() {
launch {
saveProgressFlow.value = true
val validationPayload = NetworkNodePayload(
chain = chainRegistry.getChain(payload.chainId),
nodeUrl = nodeUrlInput.value
)
validationExecutor.requireValid(
validationSystem = customNodeInteractor.getValidationSystem(
viewModelScope,
skipNodeExistValidation = payload.mode is CustomNodePayload.Mode.Edit
),
payload = validationPayload,
progressConsumer = saveProgressFlow.progressConsumer(),
validationFailureTransformerCustom = { status, actions -> mapSaveCustomNodeFailureToUI(resourceManager, status) }
) {
saveProgressFlow.value = false
executeSaving()
}
}
}
private fun executeSaving() {
launch {
when (payload.mode) {
CustomNodePayload.Mode.Add -> customNodeInteractor.createNode(payload.chainId, nodeUrlInput.value, nodeNameInput.value)
is CustomNodePayload.Mode.Edit -> customNodeInteractor.updateNode(payload.chainId, payload.mode.url, nodeUrlInput.value, nodeNameInput.value)
}
router.back()
}
}
private fun getEnabledButtonState(): DescriptiveButtonState.Enabled {
return when (payload.mode) {
CustomNodePayload.Mode.Add -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.custom_node_add_button_state))
is CustomNodePayload.Mode.Edit -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.custom_node_edit_button_state))
}
}
}
@@ -0,0 +1,30 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.validation.TransformedFailure
import io.novafoundation.nova.common.validation.TransformedFailure.Default
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.NetworkNodeFailure
fun mapSaveCustomNodeFailureToUI(
resourceManager: ResourceManager,
status: ValidationStatus.NotValid<NetworkNodeFailure>
): TransformedFailure {
return when (val reason = status.reason) {
is NetworkNodeFailure.NodeAlreadyExists -> Default(
resourceManager.getString(R.string.node_already_exist_failure_title) to
resourceManager.getString(R.string.node_already_exist_failure_message, reason.node.name)
)
NetworkNodeFailure.NodeIsNotAlive -> Default(
resourceManager.getString(R.string.node_not_alive_title) to
resourceManager.getString(R.string.node_not_alive_message)
)
is NetworkNodeFailure.WrongNetwork -> Default(
resourceManager.getString(R.string.node_not_supported_by_network_title) to
resourceManager.getString(R.string.node_not_supported_by_network_message, reason.chain.name)
)
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.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_settings_impl.presentation.networkManagement.node.CustomNodeFragment
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.CustomNodePayload
@Subcomponent(
modules = [
CustomNodeModule::class
]
)
@ScreenScope
interface CustomNodeComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: CustomNodePayload
): CustomNodeComponent
}
fun inject(fragment: CustomNodeFragment)
}
@@ -0,0 +1,47 @@
package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.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.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.domain.CustomNodeInteractor
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.CustomNodePayload
import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.CustomNodeViewModel
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class CustomNodeModule {
@Provides
@IntoMap
@ViewModelKey(CustomNodeViewModel::class)
fun provideViewModel(
router: SettingsRouter,
resourceManager: ResourceManager,
payload: CustomNodePayload,
customNodeInteractor: CustomNodeInteractor,
validationExecutor: ValidationExecutor,
chainRegistry: ChainRegistry
): ViewModel {
return CustomNodeViewModel(
router,
resourceManager,
payload,
customNodeInteractor,
validationExecutor,
chainRegistry
)
}
@Provides
fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): CustomNodeViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(CustomNodeViewModel::class.java)
}
}
@@ -0,0 +1,148 @@
package io.novafoundation.nova.feature_settings_impl.presentation.settings
import android.content.Intent
import android.provider.Settings
import android.view.View
import android.widget.Toast
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.mixin.actionAwaitable.setupConfirmationDialog
import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents
import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets
import io.novafoundation.nova.common.utils.sendEmailIntent
import io.novafoundation.nova.common.view.dialog.dialog
import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.databinding.FragmentSettingsBinding
import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent
class SettingsFragment : BaseFragment<SettingsViewModel, FragmentSettingsBinding>() {
override fun createBinding() = FragmentSettingsBinding.inflate(layoutInflater)
override fun applyInsets(rootView: View) {
binder.settingsContainer.applyStatusBarInsets()
}
override fun initViews() {
binder.accountView.setWholeClickListener { viewModel.accountActionsClicked() }
binder.settingsWallets.setOnClickListener { viewModel.walletsClicked() }
binder.settingsNetworks.setOnClickListener { viewModel.networksClicked() }
binder.settingsPushNotifications.setOnClickListener { viewModel.pushNotificationsClicked() }
binder.settingsCurrency.setOnClickListener { viewModel.currenciesClicked() }
binder.settingsLanguage.setOnClickListener { viewModel.languagesClicked() }
binder.settingsAppearance.setOnClickListener { viewModel.appearanceClicked() }
binder.settingsTelegram.setOnClickListener { viewModel.telegramClicked() }
binder.settingsTwitter.setOnClickListener { viewModel.twitterClicked() }
binder.settingsYoutube.setOnClickListener { viewModel.openYoutube() }
binder.settingsWebsite.setOnClickListener { viewModel.websiteClicked() }
binder.settingsGithub.setOnClickListener { viewModel.githubClicked() }
binder.settingsTerms.setOnClickListener { viewModel.termsClicked() }
binder.settingsPrivacy.setOnClickListener { viewModel.privacyClicked() }
binder.settingsRateUs.setOnClickListener { viewModel.rateUsClicked() }
binder.settingsWiki.setOnClickListener { viewModel.wikiClicked() }
binder.settingsEmail.setOnClickListener { viewModel.emailClicked() }
binder.settingsBiometricAuth.setOnClickListener { viewModel.changeBiometricAuth() }
binder.settingsPinCodeVerification.setOnClickListener { viewModel.changePincodeVerification() }
binder.settingsSafeMode.setOnClickListener { viewModel.changeSafeMode() }
binder.settingsHideBalances.setOnClickListener { viewModel.changeHideBalances() }
binder.settingsPin.setOnClickListener { viewModel.changePinCodeClicked() }
binder.settingsCloudBackup.setOnClickListener { viewModel.cloudBackupClicked() }
binder.settingsWalletConnect.setOnClickListener { viewModel.walletConnectClicked() }
binder.settingsAvatar.setOnClickListener { viewModel.selectedWalletClicked() }
}
override fun inject() {
FeatureUtils.getFeature<SettingsFeatureComponent>(
requireContext(),
SettingsFeatureApi::class.java
)
.settingsComponentFactory()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: SettingsViewModel) {
setupConfirmationDialog(R.style.AccentAlertDialogTheme, viewModel.confirmationAwaitableAction)
observeBrowserEvents(viewModel)
viewModel.selectedWalletModel.observe {
binder.settingsAvatar.setModel(it)
binder.accountView.setAccountIcon(it.walletIcon)
binder.accountView.setTitle(it.name)
}
viewModel.pushNotificationsState.observe {
binder.settingsPushNotifications.setValue(it)
}
viewModel.selectedCurrencyFlow.observe {
binder.settingsCurrency.setValue(it.code)
}
viewModel.selectedLanguageFlow.observe {
binder.settingsLanguage.setValue(it.displayName)
}
viewModel.showBiometricNotReadyDialogEvent.observeEvent {
showBiometricNotReadyDialog()
}
viewModel.biometricAuthStatus.observe {
binder.settingsBiometricAuth.setChecked(it)
}
viewModel.biometricEventMessages.observe {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
viewModel.pinCodeVerificationStatus.observe {
binder.settingsPinCodeVerification.setChecked(it)
}
viewModel.safeModeStatus.observe {
binder.settingsSafeMode.setChecked(it)
}
viewModel.hideBalancesOnLaunchState.observe {
binder.settingsHideBalances.setChecked(it)
}
viewModel.appVersionFlow.observe(binder.settingsAppVersion::setText)
viewModel.openEmailEvent.observeEvent { requireContext().sendEmailIntent(it) }
viewModel.walletConnectSessionsUi.observe(binder.settingsWalletConnect::setValue)
}
override fun onResume() {
super.onResume()
viewModel.onResume()
}
private fun showBiometricNotReadyDialog() {
dialog(requireContext(), customStyle = R.style.AccentAlertDialogTheme) {
setTitle(R.string.settings_biometric_not_ready_title)
setMessage(R.string.settings_biometric_not_ready_message)
setNegativeButton(R.string.common_cancel, null)
setPositiveButton(getString(R.string.common_settings)) { _, _ ->
startActivity(Intent(Settings.ACTION_SETTINGS))
}
}
}
override fun onPause() {
super.onPause()
viewModel.onPause()
}
}

Some files were not shown because too many files have changed in this diff Show More