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
@@ -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()
}
}
@@ -0,0 +1,297 @@
package io.novafoundation.nova.feature_settings_impl.presentation.settings
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.data.network.AppLinksProvider
import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase
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.actionAwaitable.fromRes
import io.novafoundation.nova.common.mixin.api.Browserable
import io.novafoundation.nova.common.resources.AppVersionProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.resources.formatBooleanToState
import io.novafoundation.nova.common.sequrity.SafeModeService
import io.novafoundation.nova.common.sequrity.TwoFactorVerificationResult
import io.novafoundation.nova.common.sequrity.TwoFactorVerificationService
import io.novafoundation.nova.common.sequrity.biometry.BiometricResponse
import io.novafoundation.nova.common.sequrity.biometry.BiometricService
import io.novafoundation.nova.common.sequrity.biometry.mapBiometricErrors
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.event
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.inBackground
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.presenatation.language.LanguageUseCase
import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor
import io.novafoundation.nova.feature_currency_api.presentation.mapper.mapCurrencyToUI
import io.novafoundation.nova.feature_push_notifications.data.PushNotificationsAvailabilityState
import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase
import io.novafoundation.nova.feature_wallet_connect_api.presentation.mapNumberOfActiveSessionsToUi
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class SettingsViewModel(
private val languageUseCase: LanguageUseCase,
private val router: SettingsRouter,
private val appLinksProvider: AppLinksProvider,
private val resourceManager: ResourceManager,
private val appVersionProvider: AppVersionProvider,
private val selectedAccountUseCase: SelectedAccountUseCase,
private val currencyInteractor: CurrencyInteractor,
private val safeModeService: SafeModeService,
private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
private val walletConnectSessionsUseCase: WalletConnectSessionsUseCase,
private val twoFactorVerificationService: TwoFactorVerificationService,
private val biometricService: BiometricService,
private val pushNotificationsInteractor: PushNotificationsInteractor,
private val maskingModeUseCase: MaskingModeUseCase
) : BaseViewModel(), Browserable {
val confirmationAwaitableAction = actionAwaitableMixinFactory.confirmingAction<ConfirmationDialogInfo>()
val selectedWalletModel = selectedAccountUseCase.selectedWalletModelFlow()
.shareInBackground()
val selectedCurrencyFlow = currencyInteractor.observeSelectCurrency()
.map { mapCurrencyToUI(it) }
.inBackground()
.share()
val selectedLanguageFlow = flowOf {
languageUseCase.selectedLanguageModel()
}
.shareInBackground()
val appVersionFlow = flowOf {
resourceManager.getString(R.string.about_version_template, appVersionProvider.versionName)
}
.inBackground()
.share()
val pinCodeVerificationStatus = twoFactorVerificationService.isEnabledFlow()
private val walletConnectSessions = walletConnectSessionsUseCase.activeSessionsNumberFlow()
.shareInBackground()
val walletConnectSessionsUi = walletConnectSessions
.map(::mapNumberOfActiveSessionsToUi)
.shareInBackground()
val safeModeStatus = safeModeService.safeModeStatusFlow()
val hideBalancesOnLaunchState = maskingModeUseCase.observeHideBalancesOnLaunchEnabled()
override val openBrowserEvent = MutableLiveData<Event<String>>()
private val _openEmailEvent = MutableLiveData<Event<String>>()
val openEmailEvent: LiveData<Event<String>> = _openEmailEvent
val biometricAuthStatus = biometricService.isEnabledFlow()
val biometricEventMessages = biometricService.biometryServiceResponseFlow
.mapNotNull { mapBiometricErrors(resourceManager, it) }
.shareInBackground()
.asLiveData()
val showBiometricNotReadyDialogEvent = biometricService.biometryServiceResponseFlow
.filterIsInstance<BiometricResponse.NotReady>()
.map { Event(true) }
.asLiveData()
val pushNotificationsState = pushNotificationsInteractor.pushNotificationsEnabledFlow()
.map { resourceManager.formatBooleanToState(it) }
.shareInBackground()
init {
setupBiometric()
}
fun walletsClicked() {
router.openWallets()
}
fun pushNotificationsClicked() {
when (pushNotificationsInteractor.pushNotificationsAvailabilityState()) {
PushNotificationsAvailabilityState.AVAILABLE -> router.openPushNotificationSettings()
PushNotificationsAvailabilityState.PLAY_SERVICES_REQUIRED -> {
showError(
resourceManager.getString(R.string.common_not_available),
resourceManager.getString(R.string.settings_push_notifications_only_available_with_google_services_error)
)
}
PushNotificationsAvailabilityState.GOOGLE_PLAY_INSTALLATION_REQUIRED -> {
showError(
resourceManager.getString(R.string.common_not_available),
resourceManager.getString(R.string.settings_push_notifications_only_available_from_google_play_error)
)
}
}
}
fun currenciesClicked() {
router.openCurrencies()
}
fun languagesClicked() {
router.openLanguages()
}
fun appearanceClicked() {
router.openAppearance()
}
fun changeBiometricAuth() {
launch {
if (biometricService.isEnabled()) {
val confirmationResult = twoFactorVerificationService.requestConfirmation(useBiometry = false)
if (confirmationResult == TwoFactorVerificationResult.CONFIRMED) {
biometricService.toggle()
}
} else {
biometricService.requestBiometric()
}
}
}
fun changePincodeVerification() {
launch {
if (!twoFactorVerificationService.isEnabled()) {
confirmationAwaitableAction.awaitAction(
ConfirmationDialogInfo.fromRes(
resourceManager,
R.string.settings_pin_code_verification_confirmation_title,
R.string.settings_pin_code_verification_confirmation_message,
R.string.common_enable,
R.string.common_cancel
)
)
}
twoFactorVerificationService.toggle()
}
}
fun changeSafeMode() {
launch {
if (!safeModeService.isSafeModeEnabled()) {
confirmationAwaitableAction.awaitAction(
ConfirmationDialogInfo.fromRes(
resourceManager,
R.string.settings_safe_mode_confirmation_title,
R.string.settings_safe_mode_confirmation_message,
R.string.common_enable,
R.string.common_cancel
)
)
}
safeModeService.toggleSafeMode()
}
}
fun changeHideBalances() {
maskingModeUseCase.toggleHideBalancesOnLaunch()
}
fun changePinCodeClicked() {
router.openChangePinCode()
}
fun telegramClicked() {
openLink(appLinksProvider.telegram)
}
fun twitterClicked() {
openLink(appLinksProvider.twitter)
}
fun rateUsClicked() {
openLink(appLinksProvider.rateApp)
}
fun wikiClicked() {
openLink(appLinksProvider.wikiBase)
}
fun websiteClicked() {
openLink(appLinksProvider.website)
}
fun githubClicked() {
openLink(appLinksProvider.github)
}
fun termsClicked() {
openLink(appLinksProvider.termsUrl)
}
fun privacyClicked() {
openLink(appLinksProvider.privacyUrl)
}
fun emailClicked() {
_openEmailEvent.value = appLinksProvider.email.event()
}
fun openYoutube() {
openLink(appLinksProvider.youtube)
}
fun accountActionsClicked() = launch {
val selectedWalletId = selectedAccountUseCase.getSelectedMetaAccount().id
router.openWalletDetails(selectedWalletId)
}
fun selectedWalletClicked() {
router.openSwitchWallet()
}
fun networksClicked() {
router.openNetworks()
}
fun walletConnectClicked() = launch {
if (walletConnectSessions.first() > 0) {
router.openWalletConnectSessions()
} else {
router.openWalletConnectScan()
}
}
fun cloudBackupClicked() {
router.openCloudBackupSettings()
}
fun onResume() {
biometricService.refreshBiometryState()
}
fun onPause() {
biometricService.cancel()
}
private fun openLink(link: String) {
openBrowserEvent.value = link.event()
}
private fun setupBiometric() {
biometricService.biometryServiceResponseFlow
.filterIsInstance<BiometricResponse.Success>()
.onEach { biometricService.toggle() }
.launchIn(this)
}
}
@@ -0,0 +1,25 @@
package io.novafoundation.nova.feature_settings_impl.presentation.settings.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
@Subcomponent(
modules = [
SettingsModule::class
]
)
@ScreenScope
interface SettingsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment
): SettingsComponent
}
fun inject(settingsFragment: io.novafoundation.nova.feature_settings_impl.presentation.settings.SettingsFragment)
}
@@ -0,0 +1,96 @@
package io.novafoundation.nova.feature_settings_impl.presentation.settings.di
import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
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.common.domain.usecase.MaskingModeUseCase
import io.novafoundation.nova.common.io.MainThreadExecutor
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
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.BiometricPromptFactory
import io.novafoundation.nova.common.sequrity.biometry.BiometricService
import io.novafoundation.nova.common.sequrity.biometry.BiometricServiceFactory
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.presenatation.language.LanguageUseCase
import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor
import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.SettingsRouter
import io.novafoundation.nova.feature_settings_impl.presentation.settings.SettingsViewModel
import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase
@Module(includes = [ViewModelModule::class])
class SettingsModule {
@Provides
@IntoMap
@ViewModelKey(SettingsViewModel::class)
fun provideViewModel(
languageUseCase: LanguageUseCase,
router: SettingsRouter,
appLinksProvider: AppLinksProvider,
resourceManager: ResourceManager,
appVersionProvider: AppVersionProvider,
selectedAccountUseCase: SelectedAccountUseCase,
currencyInteractor: CurrencyInteractor,
safeModeService: SafeModeService,
actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
walletConnectSessionsUseCase: WalletConnectSessionsUseCase,
twoFactorVerificationService: TwoFactorVerificationService,
biometricService: BiometricService,
pushNotificationsInteractor: PushNotificationsInteractor,
maskingModeUseCase: MaskingModeUseCase
): ViewModel {
return SettingsViewModel(
languageUseCase,
router,
appLinksProvider,
resourceManager,
appVersionProvider,
selectedAccountUseCase,
currencyInteractor,
safeModeService,
actionAwaitableMixinFactory,
walletConnectSessionsUseCase,
twoFactorVerificationService,
biometricService,
pushNotificationsInteractor,
maskingModeUseCase
)
}
@Provides
fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): SettingsViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(SettingsViewModel::class.java)
}
@Provides
fun provideBiometricService(
fragment: Fragment,
context: Context,
resourceManager: ResourceManager,
biometricServiceFactory: BiometricServiceFactory
): BiometricService {
val biometricManager = BiometricManager.from(context)
val biometricPromptFactory = BiometricPromptFactory(fragment, MainThreadExecutor())
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(resourceManager.getString(R.string.biometric_auth_title))
.setSubtitle(resourceManager.getString(R.string.pincode_biometry_dialog_subtitle))
.setNegativeButtonText(resourceManager.getString(R.string.common_cancel))
.build()
return biometricServiceFactory.create(biometricManager, biometricPromptFactory, promptInfo)
}
}
@@ -0,0 +1,59 @@
package io.novafoundation.nova.feature_settings_impl.presentation.settings.view
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.widget.LinearLayout
import androidx.appcompat.widget.AppCompatTextView
import io.novafoundation.nova.common.utils.WithContextExtensions
import io.novafoundation.nova.common.utils.getDrawableCompat
import io.novafoundation.nova.common.utils.inflater
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.utils.themed
import io.novafoundation.nova.feature_settings_impl.R
import io.novafoundation.nova.feature_settings_impl.databinding.ViewWalletConnectItemBinding
import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectSessionsModel
class WalletConnectItemView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr) {
private val binder = ViewWalletConnectItemBinding.inflate(inflater(), this)
init {
orientation = HORIZONTAL
background = context.getDrawableCompat(R.drawable.bg_primary_list_item)
}
fun setValue(value: WalletConnectSessionsModel) {
binder.walletConnectItemValue.setModel(value)
}
}
class WalletConnectConnectionsChip @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : AppCompatTextView(context.themed(R.style.TextAppearance_NovaFoundation_Regular_Footnote), attrs, defStyleAttr),
WithContextExtensions by WithContextExtensions(context) {
init {
setTextColorRes(R.color.chip_text)
setDrawableStart(R.drawable.ic_connections, widthInDp = 12, paddingInDp = 4, tint = R.color.chip_icon)
gravity = Gravity.CENTER_VERTICAL
includeFontPadding = false
setPadding(8.dp, 4.dp, 8.dp, 4.dp)
background = getRoundedCornerDrawable(R.color.chips_background, cornerSizeDp = 8)
}
}
fun WalletConnectConnectionsChip.setModel(model: WalletConnectSessionsModel) {
setTextOrHide(model.connections)
}

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