Initial commit: Pezkuwi Wallet Android

Complete rebrand of Nova Wallet for Pezkuwichain ecosystem.

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

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

Based on Nova Wallet by Novasama Technologies GmbH
© Dijital Kurdistan Tech Institute 2026
This commit is contained in:
2026-01-23 01:31:12 +03:00
commit 31c8c5995f
7621 changed files with 425838 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+79
View File
@@ -0,0 +1,79 @@
apply plugin: 'kotlin-parcelize'
apply from: '../tests.gradle'
apply from: "../scripts/secrets.gradle"
android {
namespace 'io.novafoundation.nova.feature_wallet_connect_impl'
defaultConfig {
buildConfigField "String", "WALLET_CONNECT_PROJECT_ID", readStringSecret("WALLET_CONNECT_PROJECT_ID")
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
packagingOptions {
resources.excludes.add("META-INF/NOTICE.md")
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation project(':core-db')
implementation project(':common')
implementation project(':feature-account-api')
implementation project(':feature-wallet-api')
implementation project(':feature-external-sign-api')
implementation project(':feature-currency-api')
implementation project(':feature-wallet-connect-api')
implementation project(':feature-dapp-api')
implementation project(':caip')
implementation project(':runtime')
implementation project(':feature-deep-linking')
implementation kotlinDep
implementation androidDep
implementation materialDep
implementation constraintDep
implementation shimmerDep
implementation coroutinesDep
implementation gsonDep
implementation daggerDep
ksp daggerCompiler
implementation lifecycleDep
ksp lifecycleCompiler
implementation viewModelKtxDep
implementation liveDataKtxDep
implementation lifeCycleKtxDep
implementation retrofitDep
implementation web3jDep
implementation coroutinesFutureDep
implementation walletConnectCoreDep, withoutTransitiveAndroidX
implementation walletConnectWalletDep, withoutTransitiveAndroidX
testImplementation jUnitDep
testImplementation mockitoDep
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,16 @@
package io.novafoundation.nova.feature_wallet_connect_impl
import io.novafoundation.nova.common.navigation.ReturnableRouter
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.WalletConnectSessionDetailsPayload
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsPayload
interface WalletConnectRouter : ReturnableRouter {
fun openSessionDetails(payload: WalletConnectSessionDetailsPayload)
fun openScanPairingQrCode()
fun backToSettings()
fun openWalletConnectSessions(payload: WalletConnectSessionsPayload)
}
@@ -0,0 +1,14 @@
package io.novafoundation.nova.feature_wallet_connect_impl.data.model.evm
import com.google.gson.annotations.SerializedName
class WalletConnectEvmTransaction(
val from: String,
val to: String,
val data: String?,
val nonce: String?,
val gasPrice: String?,
@SerializedName("gasLimit", alternate = ["gas"])
val gasLimit: String?,
val value: String?,
)
@@ -0,0 +1,57 @@
package io.novafoundation.nova.feature_wallet_connect_impl.data.repository
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.core_db.dao.WalletConnectSessionsDao
import io.novafoundation.nova.core_db.model.WalletConnectPairingLocal
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectPairingAccount
import kotlinx.coroutines.flow.Flow
interface WalletConnectPairingRepository {
suspend fun addPairingAccount(pairingAccount: WalletConnectPairingAccount)
suspend fun getPairingAccount(pairingTopic: String): WalletConnectPairingAccount?
fun allPairingAccountsFlow(): Flow<List<WalletConnectPairingAccount>>
fun pairingAccountsByMetaIdFlow(metaId: Long): Flow<List<WalletConnectPairingAccount>>
suspend fun removeAllPairingsOtherThan(activePairingTopics: List<String>)
}
class RealWalletConnectPairingRepository(
private val dao: WalletConnectSessionsDao,
) : WalletConnectPairingRepository {
override suspend fun addPairingAccount(pairingAccount: WalletConnectPairingAccount) {
dao.insertPairing(mapSessionToLocal(pairingAccount))
}
override suspend fun getPairingAccount(pairingTopic: String): WalletConnectPairingAccount? {
return dao.getPairing(pairingTopic)?.let(::mapSessionFromLocal)
}
override fun allPairingAccountsFlow(): Flow<List<WalletConnectPairingAccount>> {
return dao.allPairingsFlow().mapList(::mapSessionFromLocal)
}
override fun pairingAccountsByMetaIdFlow(metaId: Long): Flow<List<WalletConnectPairingAccount>> {
return dao.pairingsByMetaIdFlow(metaId).mapList(::mapSessionFromLocal)
}
override suspend fun removeAllPairingsOtherThan(activePairingTopics: List<String>) {
dao.removeAllPairingsOtherThan(activePairingTopics)
}
private fun mapSessionToLocal(session: WalletConnectPairingAccount): WalletConnectPairingLocal {
return with(session) {
WalletConnectPairingLocal(pairingTopic = pairingTopic, metaId = metaId)
}
}
private fun mapSessionFromLocal(sessionLocal: WalletConnectPairingLocal): WalletConnectPairingAccount {
return with(sessionLocal) {
WalletConnectPairingAccount(pairingTopic = pairingTopic, metaId = metaId)
}
}
}
@@ -0,0 +1,81 @@
package io.novafoundation.nova.feature_wallet_connect_impl.data.repository
import com.walletconnect.web3.wallet.client.Wallet.Model.Session
import io.novafoundation.nova.common.utils.added
import io.novafoundation.nova.common.utils.removed
import io.novafoundation.nova.common.utils.singleReplaySharedFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
interface WalletConnectSessionRepository {
suspend fun setSessions(sessions: List<Session>)
suspend fun getSession(sessionTopic: String): Session?
fun allSessionsFlow(): Flow<List<Session>>
fun sessionFlow(sessionTopic: String): Flow<Session?>
suspend fun addSession(session: Session)
suspend fun removeSession(sessionTopic: String)
fun numberOfSessionsFlow(): Flow<Int>
suspend fun numberOfSessionAccounts(): Int
fun numberOfSessionsFlow(pairingTopics: Set<String>): Flow<Int>
}
class InMemoryWalletConnectSessionRepository : WalletConnectSessionRepository {
private val state = singleReplaySharedFlow<List<Session>>()
override suspend fun setSessions(sessions: List<Session>) {
state.emit(sessions)
}
override suspend fun getSession(sessionTopic: String): Session? {
return state.first().find { it.topic == sessionTopic }
}
override fun allSessionsFlow(): Flow<List<Session>> {
return state
}
override fun sessionFlow(sessionTopic: String): Flow<Session?> {
return state.map { allSessions -> allSessions.find { it.topic == sessionTopic } }
}
override suspend fun addSession(session: Session) {
modifyState { current ->
current.added(session)
}
}
override suspend fun removeSession(sessionTopic: String) {
modifyState { current ->
current.removed { it.topic == sessionTopic }
}
}
override fun numberOfSessionsFlow(): Flow<Int> {
return state.map { it.size }
}
override fun numberOfSessionsFlow(pairingTopics: Set<String>): Flow<Int> {
return state.map { sessions ->
sessions.filter { it.pairingTopic in pairingTopics }.size
}
}
override suspend fun numberOfSessionAccounts(): Int {
return state.first().size
}
private suspend fun modifyState(modify: (List<Session>) -> List<Session>) {
state.emit(modify(state.first()))
}
}
@@ -0,0 +1,62 @@
package io.novafoundation.nova.feature_wallet_connect_impl.di
import dagger.BindsInstance
import dagger.Component
import io.novafoundation.nova.caip.di.CaipApi
import io.novafoundation.nova.common.di.CommonApi
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.di.DbApi
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_external_sign_api.di.ExternalSignFeatureApi
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi
import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.scan.di.WalletConnectScanComponent
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.ApproveSessionCommunicator
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.di.WalletConnectApproveSessionComponent
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.di.WalletConnectSessionDetailsComponent
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.di.WalletConnectSessionsComponent
import io.novafoundation.nova.runtime.di.RuntimeApi
@Component(
dependencies = [
WalletConnectFeatureDependencies::class
],
modules = [
WalletConnectFeatureModule::class
]
)
@FeatureScope
interface WalletConnectFeatureComponent : WalletConnectFeatureApi {
fun walletConnectSessionsComponentFactory(): WalletConnectSessionsComponent.Factory
fun walletConnectSessionDetailsComponentFactory(): WalletConnectSessionDetailsComponent.Factory
fun walletConnectApproveSessionComponentFactory(): WalletConnectApproveSessionComponent.Factory
fun walletConnectScanComponentFactory(): WalletConnectScanComponent.Factory
@Component.Factory
interface Factory {
fun create(
@BindsInstance router: WalletConnectRouter,
@BindsInstance signCommunicator: ExternalSignCommunicator,
@BindsInstance approveSessionCommunicator: ApproveSessionCommunicator,
deps: WalletConnectFeatureDependencies
): WalletConnectFeatureComponent
}
@Component(
dependencies = [
CommonApi::class,
DbApi::class,
AccountFeatureApi::class,
RuntimeApi::class,
CaipApi::class,
ExternalSignFeatureApi::class
]
)
interface WalletConnectFeatureDependenciesComponent : WalletConnectFeatureDependencies
}
@@ -0,0 +1,59 @@
package io.novafoundation.nova.feature_wallet_connect_impl.di
import android.content.Context
import coil.ImageLoader
import com.google.gson.Gson
import io.novafoundation.nova.caip.caip2.Caip2Parser
import io.novafoundation.nova.caip.caip2.Caip2Resolver
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.coroutines.RootScope
import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.core_db.dao.WalletConnectSessionsDao
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.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletMixin
import io.novafoundation.nova.feature_external_sign_api.domain.sign.evm.EvmTypedMessageParser
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
interface WalletConnectFeatureDependencies {
val accountRepository: AccountRepository
val resourceManager: ResourceManager
val selectedAccountUseCase: SelectedAccountUseCase
val addressIconGenerator: AddressIconGenerator
val gson: Gson
val chainRegistry: ChainRegistry
val imageLoader: ImageLoader
val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory
val walletUiUseCase: WalletUiUseCase
val permissionsAskerFactory: PermissionsAskerFactory
val caip2Resolver: Caip2Resolver
val caip2Parser: Caip2Parser
val evmTypedMessageParser: EvmTypedMessageParser
val sessionsDao: WalletConnectSessionsDao
val selectWalletMixinFactory: SelectWalletMixin.Factory
val appContext: Context
val automaticInteractionGate: AutomaticInteractionGate
fun rootScope(): RootScope
}
@@ -0,0 +1,37 @@
package io.novafoundation.nova.feature_wallet_connect_impl.di
import io.novafoundation.nova.caip.di.CaipApi
import io.novafoundation.nova.common.di.FeatureApiHolder
import io.novafoundation.nova.common.di.FeatureContainer
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.core_db.di.DbApi
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_external_sign_api.di.ExternalSignFeatureApi
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.ApproveSessionCommunicator
import io.novafoundation.nova.runtime.di.RuntimeApi
import javax.inject.Inject
@ApplicationScope
class WalletConnectFeatureHolder @Inject constructor(
featureContainer: FeatureContainer,
private val router: WalletConnectRouter,
private val signCommunicator: ExternalSignCommunicator,
private val approveSessionCommunicator: ApproveSessionCommunicator,
) : FeatureApiHolder(featureContainer) {
override fun initializeDependencies(): Any {
val deps = DaggerWalletConnectFeatureComponent_WalletConnectFeatureDependenciesComponent.builder()
.commonApi(commonApi())
.dbApi(getFeature(DbApi::class.java))
.accountFeatureApi(getFeature(AccountFeatureApi::class.java))
.runtimeApi(getFeature(RuntimeApi::class.java))
.caipApi(getFeature(CaipApi::class.java))
.externalSignFeatureApi(getFeature(ExternalSignFeatureApi::class.java))
.build()
return DaggerWalletConnectFeatureComponent.factory()
.create(router, signCommunicator, approveSessionCommunicator, deps)
}
}
@@ -0,0 +1,124 @@
package io.novafoundation.nova.feature_wallet_connect_impl.di
import android.content.Context
import com.google.gson.Gson
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.caip.caip2.Caip2Parser
import io.novafoundation.nova.caip.caip2.Caip2Resolver
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.coroutines.RootScope
import io.novafoundation.nova.core_db.dao.WalletConnectSessionsDao
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_external_sign_api.domain.sign.evm.EvmTypedMessageParser
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase
import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService
import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.InMemoryWalletConnectSessionRepository
import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.RealWalletConnectPairingRepository
import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.WalletConnectPairingRepository
import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.WalletConnectSessionRepository
import io.novafoundation.nova.feature_wallet_connect_impl.di.deeplinks.DeepLinkModule
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.RealWalletConnectSessionInteractor
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.RealWalletConnectSessionsUseCase
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.CompoundWalletConnectRequestFactory
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.WalletConnectRequest
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.evm.EvmWalletConnectRequestFactory
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.polkadot.PolkadotWalletConnectRequestFactory
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.service.RealWalletConnectService
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.ApproveSessionCommunicator
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.common.RealWalletConnectSessionMapper
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.common.WalletConnectSessionMapper
@Module(includes = [DeepLinkModule::class])
class WalletConnectFeatureModule {
@Provides
@FeatureScope
fun providePolkadotRequestFactory(
gson: Gson,
caip2Parser: Caip2Parser,
appContext: Context
): PolkadotWalletConnectRequestFactory {
return PolkadotWalletConnectRequestFactory(gson, caip2Parser, appContext)
}
@Provides
@FeatureScope
fun provideEvmRequestFactory(
gson: Gson,
caip2Parser: Caip2Parser,
typedMessageParser: EvmTypedMessageParser,
appContext: Context
): EvmWalletConnectRequestFactory {
return EvmWalletConnectRequestFactory(gson, caip2Parser, typedMessageParser, appContext)
}
@Provides
@FeatureScope
fun provideRequestFactory(
polkadotFactory: PolkadotWalletConnectRequestFactory,
evmFactory: EvmWalletConnectRequestFactory,
): WalletConnectRequest.Factory {
return CompoundWalletConnectRequestFactory(polkadotFactory, evmFactory)
}
@Provides
@FeatureScope
fun providePairingRepository(dao: WalletConnectSessionsDao): WalletConnectPairingRepository = RealWalletConnectPairingRepository(dao)
@Provides
@FeatureScope
fun provideSessionRepository(): WalletConnectSessionRepository = InMemoryWalletConnectSessionRepository()
@Provides
@FeatureScope
fun provideInteractor(
caip2Resolver: Caip2Resolver,
requestFactory: WalletConnectRequest.Factory,
walletConnectPairingRepository: WalletConnectPairingRepository,
walletConnectSessionRepository: WalletConnectSessionRepository,
accountRepository: AccountRepository,
caip2Parser: Caip2Parser
): WalletConnectSessionInteractor = RealWalletConnectSessionInteractor(
caip2Resolver = caip2Resolver,
walletConnectRequestFactory = requestFactory,
walletConnectPairingRepository = walletConnectPairingRepository,
accountRepository = accountRepository,
caip2Parser = caip2Parser,
walletConnectSessionRepository = walletConnectSessionRepository
)
@Provides
@FeatureScope
fun provideWalletConnectService(
rootScope: RootScope,
interactor: WalletConnectSessionInteractor,
dAppSignRequester: ExternalSignCommunicator,
approveSessionCommunicator: ApproveSessionCommunicator,
): WalletConnectService {
return RealWalletConnectService(
parentScope = rootScope,
interactor = interactor,
dAppSignRequester = dAppSignRequester,
approveSessionRequester = approveSessionCommunicator
)
}
@Provides
@FeatureScope
fun provideSessionMapper(resourceManager: ResourceManager): WalletConnectSessionMapper {
return RealWalletConnectSessionMapper(resourceManager)
}
@Provides
@FeatureScope
fun provideSessionUseCase(
pairingRepository: WalletConnectPairingRepository,
sessionRepository: WalletConnectSessionRepository
): WalletConnectSessionsUseCase {
return RealWalletConnectSessionsUseCase(pairingRepository, sessionRepository)
}
}
@@ -0,0 +1,29 @@
package io.novafoundation.nova.feature_wallet_connect_impl.di.deeplinks
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.feature_wallet_connect_api.di.deeplinks.WalletConnectDeepLinks
import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.deeplink.WalletConnectPairDeeplinkHandler
@Module
class DeepLinkModule {
@Provides
@FeatureScope
fun provideWalletConnectDeepLinkHandler(
walletConnectService: WalletConnectService,
automaticInteractionGate: AutomaticInteractionGate
) = WalletConnectPairDeeplinkHandler(
walletConnectService,
automaticInteractionGate
)
@Provides
@FeatureScope
fun provideDeepLinks(buyCallback: WalletConnectPairDeeplinkHandler): WalletConnectDeepLinks {
return WalletConnectDeepLinks(listOf(buyCallback))
}
}
@@ -0,0 +1,31 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.model
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
class SessionChains(
val required: ResolvedChains,
val optional: ResolvedChains,
) {
class ResolvedChains(val knownChains: Set<Chain>, val unknownChains: Set<String>)
}
fun SessionChains.allKnownChains(): Set<Chain> {
return required.knownChains + optional.knownChains
}
fun SessionChains.allUnknownChains(): Set<String> {
return required.unknownChains + optional.unknownChains
}
fun SessionChains.ResolvedChains.hasUnknown(): Boolean {
return unknownChains.isNotEmpty()
}
fun SessionChains.ResolvedChains.hasKnown(): Boolean {
return knownChains.isNotEmpty()
}
fun SessionChains.ResolvedChains.hasAny(): Boolean {
return hasUnknown() || hasKnown()
}
@@ -0,0 +1,6 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.model
class WalletConnectPairingAccount(
val metaId: Long,
val pairingTopic: String
)
@@ -0,0 +1,33 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.model
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
class WalletConnectSession(
val connectedMetaAccount: MetaAccount,
val dappMetadata: SessionDappMetadata?,
val sessionTopic: String,
)
class SessionDappMetadata(
val dAppUrl: String,
val icon: String?,
val name: String?
)
val SessionDappMetadata.dAppTitle: String
get() = name ?: dAppUrl
class WalletConnectSessionDetails(
val connectedMetaAccount: MetaAccount,
val dappMetadata: SessionDappMetadata?,
val chains: Set<Chain>,
val sessionTopic: String,
val status: SessionStatus,
) {
enum class SessionStatus {
ACTIVE, EXPIRED
}
}
@@ -0,0 +1,6 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.model
class WalletConnectSessionProposal(
val resolvedChains: SessionChains,
val dappMetadata: SessionDappMetadata
)
@@ -0,0 +1,106 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk
import com.walletconnect.web3.wallet.client.Wallet
import com.walletconnect.web3.wallet.client.Wallet.Model.Namespace.Session
import com.walletconnect.web3.wallet.client.Wallet.Model.SessionProposal
import com.walletconnect.web3.wallet.client.Wallet.Params.SessionApprove
import com.walletconnect.web3.wallet.client.Web3Wallet
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
fun SessionProposal.approved(namespaces: Map<String, Session>): SessionApprove {
return SessionApprove(
proposerPublicKey = proposerPublicKey,
namespaces = namespaces,
relayProtocol = relayProtocol
)
}
fun SessionProposal.rejected(reason: String): Wallet.Params.SessionReject {
return Wallet.Params.SessionReject(
proposerPublicKey = proposerPublicKey,
reason = reason
)
}
fun Wallet.Model.SessionRequest.approved(result: String): Wallet.Params.SessionRequestResponse {
return Wallet.Params.SessionRequestResponse(
sessionTopic = topic,
jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcResult(
id = request.id,
result = result
)
)
}
class WalletConnectError(val code: Int, override val message: String) : Throwable() {
companion object {
val REJECTED = WalletConnectError(5000, "Rejected by user")
val GENERAL_FAILURE = WalletConnectError(0, "Unknown error")
val NO_SESSION_FOR_TOPIC = WalletConnectError(7001, "No session for topic")
val UNAUTHORIZED_METHOD = WalletConnectError(3001, "Unauthorized method")
val CHAIN_MISMATCH = WalletConnectError(1001, "Wrong chain id passed by dApp")
fun UnknownMethod(method: String) = WalletConnectError(3001, "$method is not supported")
}
}
fun Wallet.Model.SessionRequest.failed(error: WalletConnectError): Wallet.Params.SessionRequestResponse {
return Wallet.Params.SessionRequestResponse(
sessionTopic = topic,
jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError(
id = request.id,
code = error.code,
message = error.message
)
)
}
fun Wallet.Model.SessionRequest.rejected(): Wallet.Params.SessionRequestResponse {
return failed(WalletConnectError.REJECTED)
}
suspend fun Web3Wallet.approveSession(approve: SessionApprove): Result<Unit> {
return suspendCoroutine { continuation ->
approveSession(
params = approve,
onSuccess = { continuation.resume(Result.success(Unit)) },
onError = { continuation.resume(Result.failure(it.throwable)) }
)
}
}
suspend fun Web3Wallet.rejectSession(reject: Wallet.Params.SessionReject): Result<Unit> {
return suspendCoroutine { continuation ->
rejectSession(
params = reject,
onSuccess = { continuation.resume(Result.success(Unit)) },
onError = { continuation.resume(Result.failure(it.throwable)) }
)
}
}
suspend fun Web3Wallet.disconnectSession(sessionTopic: String): Result<Unit> {
return suspendCoroutine { continuation ->
disconnectSession(
params = Wallet.Params.SessionDisconnect(sessionTopic),
onSuccess = { continuation.resume(Result.success(Unit)) },
onError = { continuation.resume(Result.failure(it.throwable)) }
)
}
}
suspend fun Web3Wallet.respondSessionRequest(response: Wallet.Params.SessionRequestResponse): Result<Unit> {
return suspendCoroutine { continuation ->
respondSessionRequest(
params = response,
onSuccess = { continuation.resume(Result.success(Unit)) },
onError = { continuation.resume(Result.failure(it.throwable)) }
)
}
}
@@ -0,0 +1,312 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.session
import com.walletconnect.android.Core
import com.walletconnect.web3.wallet.client.Wallet
import com.walletconnect.web3.wallet.client.Wallet.Model.Namespace
import com.walletconnect.web3.wallet.client.Wallet.Model.SessionProposal
import com.walletconnect.web3.wallet.client.Web3Wallet
import io.novafoundation.nova.caip.caip2.Caip2Parser
import io.novafoundation.nova.caip.caip2.Caip2Resolver
import io.novafoundation.nova.caip.caip2.isValidCaip2
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.mapValuesNotNull
import io.novafoundation.nova.common.utils.toImmutable
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.addressIn
import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.WalletConnectPairingRepository
import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.WalletConnectSessionRepository
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.SessionChains
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.SessionDappMetadata
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectPairingAccount
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSession
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSessionDetails
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSessionDetails.SessionStatus
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSessionProposal
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.WalletConnectError
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.approveSession
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.approved
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.disconnectSession
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.rejectSession
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.rejected
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.WalletConnectRequest
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
private typealias KnownChainsBuilder = MutableSet<Chain>
private typealias UnknownChainsBuilder = MutableSet<String>
private typealias Caip2ChainId = String
class RealWalletConnectSessionInteractor(
private val caip2Resolver: Caip2Resolver,
private val caip2Parser: Caip2Parser,
private val walletConnectRequestFactory: WalletConnectRequest.Factory,
private val walletConnectPairingRepository: WalletConnectPairingRepository,
private val walletConnectSessionRepository: WalletConnectSessionRepository,
private val accountRepository: AccountRepository,
) : WalletConnectSessionInteractor {
private val pendingSessionSettlementsByPairingTopic = ConcurrentHashMap<String, PendingSessionSettlement>()
override suspend fun approveSession(
sessionProposal: SessionProposal,
metaAccount: MetaAccount
): Result<Unit> = withContext(Dispatchers.Default) {
val requestedNameSpaces = sessionProposal.requiredNamespaces mergeWith sessionProposal.optionalNamespaces
val chainsByCaip2 = caip2Resolver.chainsByCaip2()
val namespaceSessions = requestedNameSpaces.mapValuesNotNull { (namespaceRaw, namespaceProposal) ->
val requestedChains = if (caip2Parser.isValidCaip2(namespaceRaw)) {
listOf(namespaceRaw)
} else {
namespaceProposal.chains
} ?: return@mapValuesNotNull null
val supportedChainsWithAccounts = requestedChains.mapNotNull { requestedChain ->
val chain = chainsByCaip2[requestedChain] ?: return@mapNotNull null
val address = metaAccount.addressIn(chain) ?: return@mapNotNull null
formatWalletConnectAccount(address, requestedChain) to requestedChain
}
Namespace.Session(
chains = supportedChainsWithAccounts.map { (_, chain) -> chain },
accounts = supportedChainsWithAccounts.map { (address, _) -> address },
methods = namespaceProposal.methods,
events = namespaceProposal.events
)
}
val response = sessionProposal.approved(namespaceSessions)
Web3Wallet.approveSession(response)
.onSuccess { registerPendingSettlement(sessionProposal, metaAccount) }
}
override suspend fun resolveSessionProposal(sessionProposal: SessionProposal): WalletConnectSessionProposal = withContext(Dispatchers.Default) {
val chainsByCaip2 = caip2Resolver.chainsByCaip2()
WalletConnectSessionProposal(
resolvedChains = SessionChains(
required = chainsByCaip2.resolveChains(sessionProposal.requiredNamespaces.caip2ChainsByNamespace()),
optional = chainsByCaip2.resolveChains(sessionProposal.optionalNamespaces.caip2ChainsByNamespace())
),
dappMetadata = SessionDappMetadata(
dAppUrl = sessionProposal.url,
icon = sessionProposal.icons.firstOrNull()?.toString(),
name = sessionProposal.name
)
)
}
override suspend fun rejectSession(proposal: SessionProposal): Result<Unit> = withContext(Dispatchers.Default) {
val response = proposal.rejected("Rejected by user")
Web3Wallet.rejectSession(response)
}
override suspend fun parseSessionRequest(request: Wallet.Model.SessionRequest): Result<WalletConnectRequest> = runCatching {
withContext(Dispatchers.Default) {
walletConnectRequestFactory.create(request) ?: throw WalletConnectError.UnknownMethod(request.request.method)
}
}
override suspend fun onSessionSettled(settledSessionResponse: Wallet.Model.SettledSessionResponse) {
if (settledSessionResponse !is Wallet.Model.SettledSessionResponse.Result) return
val pairingTopic = settledSessionResponse.session.pairingTopic
val pendingSessionSettlement = pendingSessionSettlementsByPairingTopic[pairingTopic] ?: return
val walletConnectPairingAccount = WalletConnectPairingAccount(metaId = pendingSessionSettlement.metaId, pairingTopic = pairingTopic)
walletConnectPairingRepository.addPairingAccount(walletConnectPairingAccount)
walletConnectSessionRepository.addSession(settledSessionResponse.session)
}
override suspend fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) {
if (sessionDelete !is Wallet.Model.SessionDelete.Success) return
walletConnectSessionRepository.removeSession(sessionDelete.topic)
}
override suspend fun getSession(sessionTopic: String): Wallet.Model.Session? {
return walletConnectSessionRepository.getSession(sessionTopic)
}
override suspend fun getPairingAccount(pairingTopic: String): WalletConnectPairingAccount? {
return walletConnectPairingRepository.getPairingAccount(pairingTopic)
}
override fun activeSessionsFlow(metaId: Long?): Flow<List<WalletConnectSession>> {
return combine(
walletConnectSessionRepository.allSessionsFlow(),
walletConnectPairingRepository.allPairingAccountsFlow()
) { activeSessions, pairingAccounts ->
val metaAccounts = if (metaId == null) {
accountRepository.getActiveMetaAccounts()
} else {
listOf(accountRepository.getMetaAccount(metaId))
}
createWalletSessions(activeSessions, metaAccounts, pairingAccounts)
}
}
override fun activeSessionFlow(sessionTopic: String): Flow<WalletConnectSessionDetails?> {
val sessionFlow = walletConnectSessionRepository.sessionFlow(sessionTopic)
val chainsWrappedFlow = flowOf { caip2Resolver.chainsByCaip2() }
return combine(sessionFlow, chainsWrappedFlow) { session, chainsByCaip2 ->
if (session == null) return@combine null
val pairingAccount = walletConnectPairingRepository.getPairingAccount(session.pairingTopic) ?: return@combine null
val metaAccount = accountRepository.getMetaAccount(pairingAccount.metaId)
createWalletSessionDetails(session, metaAccount, chainsByCaip2)
}
}
override suspend fun disconnect(sessionTopic: String): Result<*> {
return withContext(Dispatchers.Default) {
Web3Wallet.disconnectSession(sessionTopic).onSuccess {
walletConnectSessionRepository.removeSession(sessionTopic)
}
}
}
private infix fun Map<String, Namespace.Proposal>.mergeWith(other: Map<String, Namespace.Proposal>): Map<String, Namespace.Proposal> {
val allNamespaceKeys = keys + other.keys
return allNamespaceKeys.associateWith { namespace ->
val thisProposal = get(namespace)
val otherProposal = other[namespace]
thisProposal.orEmpty() + otherProposal.orEmpty()
}
}
private operator fun Namespace.Proposal.plus(other: Namespace.Proposal): Namespace.Proposal {
val mergedChains = chains.orEmpty() + other.chains.orEmpty()
val mergedMethods = methods + other.methods
val mergedEvents = events + other.events
return Namespace.Proposal(
chains = mergedChains.distinct(),
methods = mergedMethods.distinct(),
events = mergedEvents.distinct()
)
}
private fun Namespace.Proposal?.orEmpty(): Namespace.Proposal {
return this ?: Namespace.Proposal(
chains = null,
methods = emptyList(),
events = emptyList()
)
}
private fun formatWalletConnectAccount(address: String, chainCaip2: String): String {
return "$chainCaip2:$address"
}
private fun registerPendingSettlement(sessionProposal: SessionProposal, metaAccount: MetaAccount) {
pendingSessionSettlementsByPairingTopic[sessionProposal.pairingTopic] = PendingSessionSettlement(metaAccount.id)
}
private class PendingSessionSettlement(val metaId: Long)
private fun createWalletSessions(
sessions: List<Wallet.Model.Session>,
metaAccounts: List<MetaAccount>,
pairingAccounts: List<WalletConnectPairingAccount>
): List<WalletConnectSession> {
val metaAccountsById = metaAccounts.associateBy(MetaAccount::id)
val pairingAccountByPairingTopic = pairingAccounts.associateBy(WalletConnectPairingAccount::pairingTopic)
return sessions.mapNotNull { session ->
val pairingAccount = pairingAccountByPairingTopic[session.pairingTopic] ?: return@mapNotNull null
val metaAccount = metaAccountsById[pairingAccount.metaId] ?: return@mapNotNull null
WalletConnectSession(
connectedMetaAccount = metaAccount,
dappMetadata = session.metaData?.let(::mapAppMetadataToSessionMetadata),
sessionTopic = session.topic
)
}
}
private fun createWalletSessionDetails(
session: Wallet.Model.Session,
metaAccount: MetaAccount,
chainsByCaip2: Map<String, Chain>,
): WalletConnectSessionDetails {
return WalletConnectSessionDetails(
connectedMetaAccount = metaAccount,
dappMetadata = session.metaData?.let(::mapAppMetadataToSessionMetadata),
sessionTopic = session.topic,
chains = chainsByCaip2.resolveChains(session.namespaces.caip2ChainsByNamespace()).knownChains,
status = determineSessionStatus(session)
)
}
private fun Map<String, Chain>.resolveChains(namespaces: Map<String, List<Caip2ChainId>?>): SessionChains.ResolvedChains {
val knownChainsBuilder = mutableSetOf<Chain>()
val unknownChainsBuilder = mutableSetOf<Caip2ChainId>()
namespaces.forEach { (namespaceName, namespaceChains) ->
if (caip2Parser.isValidCaip2(namespaceName)) {
resolveChain(namespaceName, knownChainsBuilder, unknownChainsBuilder)
return@forEach
}
namespaceChains.orEmpty().forEach { chainCaip2 ->
resolveChain(chainCaip2, knownChainsBuilder, unknownChainsBuilder)
}
}
return SessionChains.ResolvedChains(knownChainsBuilder.toImmutable(), unknownChainsBuilder.toImmutable())
}
private fun Map<String, Chain>.resolveChain(
chainCaip2: String,
knownChainsBuilder: KnownChainsBuilder,
unknownChainsBuilder: UnknownChainsBuilder
) {
val newChain = get(chainCaip2)
if (newChain != null) {
knownChainsBuilder.add(newChain)
} else {
unknownChainsBuilder.add(chainCaip2)
}
}
private fun mapAppMetadataToSessionMetadata(metadata: Core.Model.AppMetaData): SessionDappMetadata {
return SessionDappMetadata(
dAppUrl = metadata.url,
icon = metadata.icons.firstOrNull(),
name = metadata.name
)
}
private fun determineSessionStatus(session: Wallet.Model.Session): SessionStatus {
return if (session.expiry > System.currentTimeMillis()) {
SessionStatus.EXPIRED
} else {
SessionStatus.ACTIVE
}
}
@JvmName("caip2ChainsByNamespaceForProposal")
private fun Map<String, Namespace.Proposal>.caip2ChainsByNamespace(): Map<String, List<Caip2ChainId>?> {
return mapValues { it.value.chains }
}
@JvmName("caip2ChainsByNamespaceForSession")
private fun Map<String, Namespace.Session>.caip2ChainsByNamespace(): Map<String, List<Caip2ChainId>?> {
return mapValues { it.value.chains }
}
}
@@ -0,0 +1,45 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.session
import com.walletconnect.android.CoreClient
import com.walletconnect.web3.wallet.client.Web3Wallet
import io.novafoundation.nova.common.utils.mapToSet
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase
import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.WalletConnectPairingRepository
import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.WalletConnectSessionRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.withContext
internal class RealWalletConnectSessionsUseCase(
private val pairingRepository: WalletConnectPairingRepository,
private val sessionRepository: WalletConnectSessionRepository,
) : WalletConnectSessionsUseCase {
override fun activeSessionsNumberFlow(): Flow<Int> {
return sessionRepository.numberOfSessionsFlow()
}
override fun activeSessionsNumberFlow(metaAccount: MetaAccount): Flow<Int> {
return pairingRepository.pairingAccountsByMetaIdFlow(metaAccount.id).flatMapLatest { pairings ->
val pairingTopics = pairings.mapToSet { it.pairingTopic }
sessionRepository.numberOfSessionsFlow(pairingTopics)
}
}
override suspend fun activeSessionsNumber(): Int {
return sessionRepository.numberOfSessionAccounts()
}
override suspend fun syncActiveSessions() = withContext(Dispatchers.Default) {
val activePairingTopics = CoreClient.Pairing.getPairings()
.filter { it.isActive }
.map { it.topic }
pairingRepository.removeAllPairingsOtherThan(activePairingTopics)
val activeSessionTopics = Web3Wallet.getListOfActiveSessions()
sessionRepository.setSessions(activeSessionTopics)
}
}
@@ -0,0 +1,39 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.session
import com.walletconnect.web3.wallet.client.Wallet
import com.walletconnect.web3.wallet.client.Wallet.Model.SessionProposal
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectPairingAccount
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSession
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSessionDetails
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSessionProposal
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.WalletConnectRequest
import kotlinx.coroutines.flow.Flow
interface WalletConnectSessionInteractor {
suspend fun approveSession(
sessionProposal: SessionProposal,
metaAccount: MetaAccount
): Result<Unit>
suspend fun resolveSessionProposal(sessionProposal: SessionProposal): WalletConnectSessionProposal
suspend fun rejectSession(proposal: SessionProposal): Result<Unit>
suspend fun parseSessionRequest(request: Wallet.Model.SessionRequest): Result<WalletConnectRequest>
suspend fun onSessionSettled(settledSessionResponse: Wallet.Model.SettledSessionResponse)
suspend fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete)
suspend fun getSession(sessionTopic: String): Wallet.Model.Session?
suspend fun getPairingAccount(pairingTopic: String): WalletConnectPairingAccount?
fun activeSessionsFlow(metaId: Long?): Flow<List<WalletConnectSession>>
fun activeSessionFlow(sessionTopic: String): Flow<WalletConnectSessionDetails?>
suspend fun disconnect(sessionTopic: String): Result<*>
}
@@ -0,0 +1,66 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests
import android.content.Context
import com.walletconnect.web3.wallet.client.Wallet
import com.walletconnect.web3.wallet.client.Web3Wallet
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.WalletConnectError
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.failed
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.rejected
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.respondSessionRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
abstract class BaseWalletConnectRequest(
private val sessionRequest: Wallet.Model.SessionRequest,
private val context: Context,
) : WalletConnectRequest {
override val id: String = sessionRequest.request.id.toString()
abstract suspend fun sentResponse(response: ExternalSignCommunicator.Response.Sent): Wallet.Params.SessionRequestResponse
abstract suspend fun signedResponse(response: ExternalSignCommunicator.Response.Signed): Wallet.Params.SessionRequestResponse
override suspend fun respondWith(response: ExternalSignCommunicator.Response): Result<*> = kotlin.runCatching {
withContext(Dispatchers.Default) {
val walletConnectResponse = when (response) {
is ExternalSignCommunicator.Response.Rejected -> sessionRequest.rejected()
is ExternalSignCommunicator.Response.Sent -> sentResponse(response)
is ExternalSignCommunicator.Response.Signed -> signedResponse(response)
is ExternalSignCommunicator.Response.SigningFailed -> sessionRequest.failed(WalletConnectError.GENERAL_FAILURE)
}
Web3Wallet.respondSessionRequest(walletConnectResponse).getOrThrow()
// TODO this code is untested since no dapp currently use redirect param
// We cant really enable this code without testing since we need to verify a corner-case when wc is used with redirect param inside dapp browser
// This might potentially break user flow since it might direct user to external browser instead of staying in our dapp browser
// val redirect = sessionRequest.peerMetaData?.redirect
// if (!redirect.isNullOrEmpty()) {
// context.startActivity(Intent(Intent.ACTION_VIEW, redirect.toUri()))
// }
}
}
}
abstract class SignWalletConnectRequest(
sessionRequest: Wallet.Model.SessionRequest,
context: Context
) : BaseWalletConnectRequest(sessionRequest, context) {
override suspend fun sentResponse(response: ExternalSignCommunicator.Response.Sent): Wallet.Params.SessionRequestResponse {
error("Expected Signed response, got: Sent")
}
}
abstract class SendTxWalletConnectRequest(
sessionRequest: Wallet.Model.SessionRequest,
context: Context
) : BaseWalletConnectRequest(sessionRequest, context) {
override suspend fun signedResponse(response: ExternalSignCommunicator.Response.Signed): Wallet.Params.SessionRequestResponse {
error("Expected Sent response, got: Signed")
}
}
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests
import com.walletconnect.web3.wallet.client.Wallet
import io.novasama.substrate_sdk_android.extensions.tryFindNonNull
class CompoundWalletConnectRequestFactory(
private val nestedFactories: List<WalletConnectRequest.Factory>
) : WalletConnectRequest.Factory {
override fun create(sessionRequest: Wallet.Model.SessionRequest): WalletConnectRequest? {
return nestedFactories.tryFindNonNull { it.create(sessionRequest) }
}
}
fun CompoundWalletConnectRequestFactory(vararg factories: WalletConnectRequest.Factory): CompoundWalletConnectRequestFactory {
return CompoundWalletConnectRequestFactory(factories.toList())
}
@@ -0,0 +1,19 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests
import com.walletconnect.web3.wallet.client.Wallet
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest
interface WalletConnectRequest {
interface Factory {
fun create(sessionRequest: Wallet.Model.SessionRequest): WalletConnectRequest?
}
val id: String
suspend fun respondWith(response: ExternalSignCommunicator.Response): Result<*>
fun toExternalSignRequest(): ExternalSignRequest
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.evm
import android.content.Context
import com.walletconnect.web3.wallet.client.Wallet
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmPersonalSignMessage
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.approved
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.SignWalletConnectRequest
class EvmPersonalSignRequest(
private val originAddress: String,
private val message: EvmPersonalSignMessage,
private val sessionRequest: Wallet.Model.SessionRequest,
context: Context
) : SignWalletConnectRequest(sessionRequest, context) {
override suspend fun signedResponse(response: ExternalSignCommunicator.Response.Signed): Wallet.Params.SessionRequestResponse {
return sessionRequest.approved(response.signature)
}
override fun toExternalSignRequest(): ExternalSignRequest {
val signPayload = EvmSignPayload.PersonalSign(message, originAddress)
return ExternalSignRequest.Evm(id, signPayload)
}
}
@@ -0,0 +1,35 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.evm
import android.content.Context
import com.walletconnect.web3.wallet.client.Wallet
import com.walletconnect.web3.wallet.client.Wallet.Params.SessionRequestResponse
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator.Response
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChainSource
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTransaction
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.approved
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.SendTxWalletConnectRequest
class EvmSendTransactionRequest(
private val transaction: EvmTransaction.Struct,
private val chainId: Int,
private val sessionRequest: Wallet.Model.SessionRequest,
context: Context
) : SendTxWalletConnectRequest(sessionRequest, context) {
override suspend fun sentResponse(response: Response.Sent): SessionRequestResponse {
return sessionRequest.approved(response.txHash)
}
override fun toExternalSignRequest(): ExternalSignRequest {
val signPayload = EvmSignPayload.ConfirmTx(
transaction = transaction,
originAddress = transaction.from,
chainSource = EvmChainSource(chainId, EvmChainSource.UnknownChainOptions.MustBeKnown),
action = EvmSignPayload.ConfirmTx.Action.SEND
)
return ExternalSignRequest.Evm(id, signPayload)
}
}
@@ -0,0 +1,35 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.evm
import android.content.Context
import com.walletconnect.web3.wallet.client.Wallet
import com.walletconnect.web3.wallet.client.Wallet.Params.SessionRequestResponse
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator.Response
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChainSource
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTransaction
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.approved
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.SignWalletConnectRequest
class EvmSignTransactionRequest(
private val transaction: EvmTransaction.Struct,
private val chainId: Int,
private val sessionRequest: Wallet.Model.SessionRequest,
context: Context
) : SignWalletConnectRequest(sessionRequest, context) {
override suspend fun signedResponse(response: Response.Signed): SessionRequestResponse {
return sessionRequest.approved(response.signature)
}
override fun toExternalSignRequest(): ExternalSignRequest {
val signPayload = EvmSignPayload.ConfirmTx(
transaction = transaction,
originAddress = transaction.from,
chainSource = EvmChainSource(chainId, EvmChainSource.UnknownChainOptions.MustBeKnown),
action = EvmSignPayload.ConfirmTx.Action.SIGN
)
return ExternalSignRequest.Evm(id, signPayload)
}
}
@@ -0,0 +1,29 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.evm
import android.content.Context
import com.walletconnect.web3.wallet.client.Wallet
import com.walletconnect.web3.wallet.client.Wallet.Params.SessionRequestResponse
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator.Response
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTypedMessage
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.approved
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.SignWalletConnectRequest
class EvmSignTypedDataRequest(
private val originAddress: String,
private val typedMessage: EvmTypedMessage,
private val sessionRequest: Wallet.Model.SessionRequest,
context: Context
) : SignWalletConnectRequest(sessionRequest, context) {
override suspend fun signedResponse(response: Response.Signed): SessionRequestResponse {
return sessionRequest.approved(response.signature)
}
override fun toExternalSignRequest(): ExternalSignRequest {
val signPayload = EvmSignPayload.SignTypedMessage(typedMessage, originAddress)
return ExternalSignRequest.Evm(id, signPayload)
}
}
@@ -0,0 +1,109 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.evm
import android.content.Context
import com.google.gson.Gson
import com.walletconnect.web3.wallet.client.Wallet
import io.novafoundation.nova.caip.caip2.Caip2Parser
import io.novafoundation.nova.caip.caip2.identifier.Caip2Identifier
import io.novafoundation.nova.common.utils.castOrNull
import io.novafoundation.nova.common.utils.fromJson
import io.novafoundation.nova.feature_external_sign_api.domain.sign.evm.EvmTypedMessageParser
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmPersonalSignMessage
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTransaction
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTypedMessage
import io.novafoundation.nova.feature_wallet_connect_impl.data.model.evm.WalletConnectEvmTransaction
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.WalletConnectRequest
class EvmWalletConnectRequestFactory(
private val gson: Gson,
private val caip2Parser: Caip2Parser,
private val typedMessageParser: EvmTypedMessageParser,
private val context: Context
) : WalletConnectRequest.Factory {
override fun create(sessionRequest: Wallet.Model.SessionRequest): WalletConnectRequest? {
val request = sessionRequest.request
return when (request.method) {
"eth_sendTransaction" -> parseEvmSendTx(sessionRequest, sessionRequest.eipChainId())
"eth_signTransaction" -> parseEvmSignTx(sessionRequest, sessionRequest.eipChainId())
"eth_signTypedData", "eth_signTypedData_v4" -> parseEvmSignTypedMessage(sessionRequest)
"personal_sign" -> parsePersonalSign(sessionRequest)
else -> null
}
}
private fun parsePersonalSign(sessionRequest: Wallet.Model.SessionRequest): WalletConnectRequest {
val (message, address) = gson.fromJson<List<String>>(sessionRequest.request.params)
val personalSignMessage = EvmPersonalSignMessage(message)
return EvmPersonalSignRequest(address, personalSignMessage, sessionRequest, context)
}
private fun parseEvmSignTypedMessage(sessionRequest: Wallet.Model.SessionRequest): WalletConnectRequest {
val (address, typedMessage) = parseEvmSignTypedDataParams(sessionRequest.request.params)
return EvmSignTypedDataRequest(address, typedMessage, sessionRequest, context)
}
private fun parseEvmSendTx(sessionRequest: Wallet.Model.SessionRequest, chainId: Int): WalletConnectRequest {
val transaction = parseStructTransaction(sessionRequest.request.params)
return EvmSendTransactionRequest(transaction, chainId, sessionRequest, context)
}
private fun parseEvmSignTx(sessionRequest: Wallet.Model.SessionRequest, chainId: Int): WalletConnectRequest {
val transaction = parseStructTransaction(sessionRequest.request.params)
return EvmSignTransactionRequest(transaction, chainId, sessionRequest, context)
}
private fun Wallet.Model.SessionRequest.eipChainId(): Int {
return chainId?.let(::extractEvmChainId)
?: error("No chain id supplied for ${request.method}")
}
private fun parseStructTransaction(params: String): EvmTransaction.Struct {
val parsed: WalletConnectEvmTransaction = parseSingleEvmParameter(params)
return with(parsed) {
EvmTransaction.Struct(
gas = gasLimit,
gasPrice = gasPrice,
from = from,
to = to,
data = data,
value = value,
nonce = nonce
)
}
}
private fun parseEvmSignTypedDataParams(params: String): Pair<String, EvmTypedMessage> {
// params = ["addressParam", structuredDataObject]
val (addressParam, structuredData) = params.removeSurrounding("[", "]").split(',', limit = 2)
val address = addressParam.removeSurrounding("\"")
val evmTypedMessage = typedMessageParser.parseEvmTypedMessage(structuredData)
return address to evmTypedMessage
}
private inline fun <reified T : List<I>, reified I> parseSingleEvmParameter(params: String): I {
// gson.fromJson<List<I>>(params) does not work even with inlining - gson ignores inner list types and creates hash map instead
val parsed = gson.fromJson<T>(params)
return parsed.first()
}
private fun extractEvmChainId(caip2: String): Int? {
return caip2Parser.parseCaip2(caip2)
.getOrNull()
?.castOrNull<Caip2Identifier.Eip155>()
?.chainId?.toInt()
}
}
@@ -0,0 +1,30 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.polkadot
import android.content.Context
import com.google.gson.Gson
import com.walletconnect.web3.wallet.client.Wallet
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.PolkadotSignPayload
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.PolkadotSignerResult
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.approved
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.SignWalletConnectRequest
class PolkadotSignRequest(
private val gson: Gson,
private val polkadotSignPayload: PolkadotSignPayload,
private val sessionRequest: Wallet.Model.SessionRequest,
context: Context
) : SignWalletConnectRequest(sessionRequest, context) {
override suspend fun signedResponse(response: ExternalSignCommunicator.Response.Signed): Wallet.Params.SessionRequestResponse {
val responseData = PolkadotSignerResult(id, signature = response.signature, response.modifiedTransaction)
val responseJson = gson.toJson(responseData)
return sessionRequest.approved(responseJson)
}
override fun toExternalSignRequest(): ExternalSignRequest {
return ExternalSignRequest.Polkadot(id, polkadotSignPayload)
}
}
@@ -0,0 +1,66 @@
package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.polkadot
import android.content.Context
import com.google.gson.Gson
import com.walletconnect.web3.wallet.client.Wallet.Model.SessionRequest
import io.novafoundation.nova.caip.caip2.Caip2Parser
import io.novafoundation.nova.caip.caip2.identifier.Caip2Identifier
import io.novafoundation.nova.caip.caip2.parseCaip2OrThrow
import io.novafoundation.nova.common.utils.fromJson
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.PolkadotSignPayload
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.WalletConnectError
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.WalletConnectRequest
class PolkadotWalletConnectRequestFactory(
private val gson: Gson,
private val caip2Parser: Caip2Parser,
private val context: Context
) : WalletConnectRequest.Factory {
override fun create(sessionRequest: SessionRequest): WalletConnectRequest? {
val request = sessionRequest.request
return when (request.method) {
"polkadot_signTransaction" -> parseSignTransactionRequest(sessionRequest)
"polkadot_signMessage" -> parseSignMessageRequest(sessionRequest)
else -> null
}
}
private fun parseSignTransactionRequest(sessionRequest: SessionRequest): WalletConnectRequest {
val signTxPayload = gson.fromJson<SignTransaction>(sessionRequest.request.params)
val caip2FromPayload = Caip2Identifier.Polkadot(signTxPayload.transactionPayload.genesisHash)
val caip2FromChainId = caip2Parser.parseCaip2OrThrow(requireNotNull(sessionRequest.chainId))
if (caip2FromChainId != caip2FromPayload) throw WalletConnectError.CHAIN_MISMATCH
return PolkadotSignRequest(
gson = gson,
polkadotSignPayload = signTxPayload.transactionPayload,
sessionRequest = sessionRequest,
context = context
)
}
private fun parseSignMessageRequest(sessionRequest: SessionRequest): WalletConnectRequest {
val signMessagePayload = gson.fromJson<SignMessage>(sessionRequest.request.params)
return PolkadotSignRequest(
gson = gson,
polkadotSignPayload = PolkadotSignPayload.Raw(
data = signMessagePayload.message,
address = signMessagePayload.address,
type = null
),
sessionRequest = sessionRequest,
context = context
)
}
}
private class SignTransaction(val transactionPayload: PolkadotSignPayload.Json)
private class SignMessage(val address: String, val message: String)
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.deeplink
import android.net.Uri
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed
import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent
import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler
import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService
import io.novafoundation.nova.feature_wallet_connect_api.presentation.utils.WalletConnectUtils
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
class WalletConnectPairDeeplinkHandler(
private val walletConnectService: WalletConnectService,
private val automaticInteractionGate: AutomaticInteractionGate
) : DeepLinkHandler {
override val callbackFlow: Flow<CallbackEvent> = emptyFlow()
override suspend fun matches(data: Uri): Boolean {
return WalletConnectUtils.isWalletConnectPairingLink(data)
}
override suspend fun handleDeepLink(data: Uri) = runCatching {
automaticInteractionGate.awaitInteractionAllowed()
walletConnectService.pair(data.toString())
}
}
@@ -0,0 +1,42 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.scan
import android.view.View
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.presentation.scan.ScanQrFragment
import io.novafoundation.nova.common.presentation.scan.ScanView
import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets
import io.novafoundation.nova.common.utils.setDrawableStart
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi
import io.novafoundation.nova.feature_wallet_connect_impl.R
import io.novafoundation.nova.feature_wallet_connect_impl.databinding.FragmentWcScanBinding
import io.novafoundation.nova.feature_wallet_connect_impl.di.WalletConnectFeatureComponent
class WalletConnectScanFragment : ScanQrFragment<WalletConnectScanViewModel, FragmentWcScanBinding>() {
override fun createBinding() = FragmentWcScanBinding.inflate(layoutInflater)
override fun inject() {
FeatureUtils.getFeature<WalletConnectFeatureComponent>(requireContext(), WalletConnectFeatureApi::class.java)
.walletConnectScanComponentFactory()
.create(this)
.inject(this)
}
override fun applyInsets(rootView: View) {
binder.walletConnectScanToolbar.applyStatusBarInsets()
}
override fun initViews() {
super.initViews()
binder.walletConnectScanToolbar.setHomeButtonListener { viewModel.backClicked() }
scanView.subtitle.setDrawableStart(R.drawable.ic_wallet_connect, widthInDp = 24, paddingInDp = 2, tint = R.color.icon_primary)
scanView.subtitle.setTextAppearance(R.style.TextAppearance_NovaFoundation_SemiBold_SubHeadline)
scanView.subtitle.setTextColorRes(R.color.text_primary)
}
override val scanView: ScanView
get() = binder.walletConnectScan
}
@@ -0,0 +1,27 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.scan
import io.novafoundation.nova.common.navigation.ReturnableRouter
import io.novafoundation.nova.common.presentation.scan.ScanQrViewModel
import io.novafoundation.nova.common.utils.permissions.PermissionsAsker
import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService
class WalletConnectScanViewModel(
private val router: ReturnableRouter,
private val permissionsAsker: PermissionsAsker.Presentation,
private val walletConnectService: WalletConnectService
) : ScanQrViewModel(permissionsAsker) {
fun backClicked() {
router.back()
}
override suspend fun scanned(result: String) {
initiatePairing(result)
router.back()
}
private fun initiatePairing(uri: String) {
walletConnectService.pair(uri)
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.scan.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_wallet_connect_impl.presentation.scan.WalletConnectScanFragment
@Subcomponent(
modules = [
WalletConnectScanModule::class
]
)
@ScreenScope
interface WalletConnectScanComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
): WalletConnectScanComponent
}
fun inject(fragment: WalletConnectScanFragment)
}
@@ -0,0 +1,42 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.scan.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.utils.permissions.PermissionsAsker
import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory
import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService
import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.scan.WalletConnectScanViewModel
@Module(includes = [ViewModelModule::class])
class WalletConnectScanModule {
@Provides
fun providePermissionAsker(
permissionsAskerFactory: PermissionsAskerFactory,
fragment: Fragment,
router: WalletConnectRouter
) = permissionsAskerFactory.createReturnable(fragment, router)
@Provides
@IntoMap
@ViewModelKey(WalletConnectScanViewModel::class)
fun provideViewModel(
router: WalletConnectRouter,
permissionsAsker: PermissionsAsker.Presentation,
walletConnectService: WalletConnectService
): ViewModel {
return WalletConnectScanViewModel(router, permissionsAsker, walletConnectService)
}
@Provides
fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): WalletConnectScanViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(WalletConnectScanViewModel::class.java)
}
}
@@ -0,0 +1,140 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.service
import android.util.Log
import androidx.lifecycle.MutableLiveData
import com.walletconnect.android.Core
import com.walletconnect.android.CoreClient
import com.walletconnect.web3.wallet.client.Wallet
import com.walletconnect.web3.wallet.client.Web3Wallet
import io.novafoundation.nova.common.navigation.awaitResponse
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions
import io.novafoundation.nova.common.utils.inBackground
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignRequester
import io.novafoundation.nova.feature_external_sign_api.model.awaitConfirmation
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignPayload
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignWallet
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.SigningDappMetadata
import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.WalletConnectError
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.failed
import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.respondSessionRequest
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.ApproveSessionRequester
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsEvent
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.sessionEventsFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
internal class RealWalletConnectService(
parentScope: CoroutineScope,
private val interactor: WalletConnectSessionInteractor,
private val dAppSignRequester: ExternalSignRequester,
private val approveSessionRequester: ApproveSessionRequester,
) : WalletConnectService,
CoroutineScope by parentScope,
WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(parentScope) {
private val events = Web3Wallet.sessionEventsFlow(scope = this)
override val onPairErrorLiveData: MutableLiveData<Event<Throwable>> = MutableLiveData()
init {
events.onEach {
when (it) {
is WalletConnectSessionsEvent.SessionProposal -> handleSessionProposal(it.proposal)
is WalletConnectSessionsEvent.SessionRequest -> handleSessionRequest(it.request)
is WalletConnectSessionsEvent.SessionSettlement -> handleSessionSettlement(it.settlement)
is WalletConnectSessionsEvent.SessionDeleted -> handleSessionDelete(it.delete)
}
}
.inBackground()
.launchIn(this)
}
override fun connect() {
CoreClient.Relay.connect { error: Core.Model.Error ->
Log.d(LOG_TAG, "Failed to connect to Wallet Connect: ", error.throwable)
}
}
override fun disconnect() {
CoreClient.Relay.disconnect { error: Core.Model.Error ->
Log.d(LOG_TAG, "Failed to disconnect to Wallet Connect: ", error.throwable)
}
}
override fun pair(uri: String) {
Web3Wallet.pair(Wallet.Params.Pair(uri), onError = { onPairErrorLiveData.postValue(Event(it.throwable)) })
}
private suspend fun handleSessionProposal(proposal: Wallet.Model.SessionProposal) = withContext(Dispatchers.Main) {
approveSessionRequester.awaitResponse(proposal)
}
private suspend fun handleSessionRequest(sessionRequest: Wallet.Model.SessionRequest) {
val sdkSession = interactor.getSession(sessionRequest.topic) ?: run { respondNoSession(sessionRequest); return }
val appPairing = interactor.getPairingAccount(sdkSession.pairingTopic) ?: run { respondNoSession(sessionRequest); return }
val walletConnectRequest = interactor.parseSessionRequest(sessionRequest)
.onFailure { error ->
Log.e("WalletConnect", "Failed to parse session request $sessionRequest", error)
respondWithError(sessionRequest, error)
return
}.getOrThrow()
val externalSignResponse = withContext(Dispatchers.Main) {
dAppSignRequester.awaitConfirmation(
ExternalSignPayload(
signRequest = walletConnectRequest.toExternalSignRequest(),
dappMetadata = mapWalletConnectSessionToSignDAppMetadata(sdkSession),
wallet = ExternalSignWallet.WithId(appPairing.metaId)
)
)
}
walletConnectRequest.respondWith(externalSignResponse)
}
private suspend fun handleSessionSettlement(settlement: Wallet.Model.SettledSessionResponse) {
interactor.onSessionSettled(settlement)
}
private suspend fun handleSessionDelete(settlement: Wallet.Model.SessionDelete) {
interactor.onSessionDelete(settlement)
}
private fun mapWalletConnectSessionToSignDAppMetadata(session: Wallet.Model.Session): SigningDappMetadata? {
return session.metaData?.run {
SigningDappMetadata(
icon = icons.firstOrNull(),
name = name,
url = url
)
}
}
private suspend fun respondNoSession(
sessionRequest: Wallet.Model.SessionRequest,
): Result<*> {
val response = sessionRequest.failed(WalletConnectError.NO_SESSION_FOR_TOPIC)
return Web3Wallet.respondSessionRequest(response)
}
private suspend fun respondWithError(
sessionRequest: Wallet.Model.SessionRequest,
exception: Throwable
): Result<*> {
val error = exception as? WalletConnectError ?: WalletConnectError.GENERAL_FAILURE
val response = sessionRequest.failed(error)
return Web3Wallet.respondSessionRequest(response)
}
}
@@ -0,0 +1,11 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve
import com.walletconnect.web3.wallet.client.Wallet.Model.SessionProposal
import io.novafoundation.nova.common.navigation.InterScreenRequester
import io.novafoundation.nova.common.navigation.InterScreenResponder
interface ApproveSessionRequester : InterScreenRequester<SessionProposal, Unit>
interface ApproveSessionResponder : InterScreenResponder<SessionProposal, Unit>
interface ApproveSessionCommunicator : ApproveSessionRequester, ApproveSessionResponder
@@ -0,0 +1,75 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.view.setState
import io.novafoundation.nova.common.view.setMessageOrHide
import io.novafoundation.nova.common.view.showValueOrHide
import io.novafoundation.nova.feature_account_api.presenatation.chain.showChainsOverview
import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.setupSelectWalletMixin
import io.novafoundation.nova.feature_external_sign_api.presentation.dapp.showDAppIcon
import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi
import io.novafoundation.nova.feature_wallet_connect_impl.databinding.FragmentWcSessionApproveBinding
import io.novafoundation.nova.feature_wallet_connect_impl.di.WalletConnectFeatureComponent
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.view.WCNetworksBottomSheet
import javax.inject.Inject
class WalletConnectApproveSessionFragment : BaseFragment<WalletConnectApproveSessionViewModel, FragmentWcSessionApproveBinding>() {
override fun createBinding() = FragmentWcSessionApproveBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
override fun initViews() {
onBackPressed { viewModel.exit() }
binder.wcApproveSessionToolbar.setHomeButtonListener { viewModel.exit() }
binder.wcApproveSessionReject.setOnClickListener { viewModel.rejectClicked() }
binder.wcApproveSessionReject.prepareForProgress(viewLifecycleOwner)
binder.wcApproveSessionAllow.prepareForProgress(viewLifecycleOwner)
binder.wcApproveSessionAllow.setOnClickListener { viewModel.approveClicked() }
binder.wcApproveSessionNetworks.setOnClickListener { viewModel.networksClicked() }
}
override fun inject() {
FeatureUtils.getFeature<WalletConnectFeatureComponent>(
requireContext(),
WalletConnectFeatureApi::class.java
)
.walletConnectApproveSessionComponentFactory()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: WalletConnectApproveSessionViewModel) {
setupSelectWalletMixin(viewModel.selectWalletMixin, binder.wcApproveSessionWallet)
viewModel.sessionMetadata.observe { sessionMetadata ->
binder.wcApproveSessionDApp.showValueOrHide(sessionMetadata.dAppUrl)
binder.wcApproveSessionIcon.showDAppIcon(sessionMetadata.icon, imageLoader)
}
viewModel.chainsOverviewFlow.observe(binder.wcApproveSessionNetworks::showChainsOverview)
viewModel.title.observe(binder.wcApproveSessionTitle::setText)
viewModel.sessionAlerts.observe { sessionAlerts ->
binder.wcApproveSessionChainsAlert.setMessageOrHide(sessionAlerts.unsupportedChains?.alertContent)
binder.wcApproveSessionAccountsAlert.setMessageOrHide(sessionAlerts.missingAccounts?.alertContent)
}
viewModel.allowButtonState.observe(binder.wcApproveSessionAllow::setState)
viewModel.rejectButtonState.observe(binder.wcApproveSessionReject::setState)
viewModel.showNetworksBottomSheet.observeEvent { data ->
WCNetworksBottomSheet(context = requireContext(), data = data)
.show()
}
}
}
@@ -0,0 +1,279 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve
import android.util.Log
import androidx.annotation.StringRes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.navigation.requireLastInput
import io.novafoundation.nova.common.navigation.respond
import io.novafoundation.nova.common.presentation.DescriptiveButtonState
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.resources.formatListPreview
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.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainListOverview
import io.novafoundation.nova.feature_account_api.presenatation.chain.iconOrFallback
import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletMixin
import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.selectedMetaAccount
import io.novafoundation.nova.feature_wallet_connect_impl.R
import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.SessionChains
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSessionProposal
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.allKnownChains
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.allUnknownChains
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.dAppTitle
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.hasAny
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.hasUnknown
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.model.SessionAlerts
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.model.hasBlockingAlerts
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.view.WCNetworkListModel
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
private const val MISSING_ACCOUNTS_PREVIEW_SIZE = 3
class WalletConnectApproveSessionViewModel(
private val router: WalletConnectRouter,
private val interactor: WalletConnectSessionInteractor,
private val responder: ApproveSessionResponder,
private val resourceManager: ResourceManager,
private val selectWalletMixinFactory: SelectWalletMixin.Factory
) : BaseViewModel() {
private val proposal = responder.requireLastInput()
val selectWalletMixin = selectWalletMixinFactory.create(
coroutineScope = this,
selectionParams = ::walletSelectionParams
)
private val processState = MutableStateFlow(ProgressState.IDLE)
private val sessionProposalFlow = flowOf {
interactor.resolveSessionProposal(proposal)
}.shareInBackground()
val sessionMetadata = sessionProposalFlow.map { it.dappMetadata }
val title = sessionMetadata.map { sessionDAppMetadata ->
val dAppTitle = sessionDAppMetadata.dAppTitle
resourceManager.getString(R.string.dapp_confirm_authorize_title_format, dAppTitle)
}.shareInBackground()
val chainsOverviewFlow = sessionProposalFlow.map { sessionProposal ->
createSessionNetworksModel(sessionProposal.resolvedChains)
}.shareInBackground()
val sessionAlerts = combine(selectWalletMixin.selectedMetaAccountFlow, sessionProposalFlow) { metaAccount, sessionProposal ->
constructSessionAlerts(metaAccount, sessionProposal)
}.shareInBackground()
val networksListFlow = sessionProposalFlow.map { constructNetworksList(it.resolvedChains) }
.shareInBackground()
val allowButtonState = allowButtonState().shareInBackground()
val rejectButtonState = rejectButtonState().shareInBackground()
private val _showNetworksBottomSheet = MutableLiveData<Event<List<WCNetworkListModel>>>()
val showNetworksBottomSheet: LiveData<Event<List<WCNetworkListModel>>> = _showNetworksBottomSheet
fun exit() {
rejectClicked()
}
fun rejectClicked() = launch {
if (isInProgress()) return@launch
processState.value = ProgressState.REJECTING
val proposal = responder.requireLastInput()
interactor.rejectSession(proposal)
responder.respond()
router.back()
}
fun approveClicked() = launch {
if (isInProgress()) return@launch
processState.value = ProgressState.CONFIRMING
val proposal = responder.requireLastInput()
val metaAccount = selectWalletMixin.selectedMetaAccount()
interactor.approveSession(proposal, metaAccount)
.onFailure {
Log.d("WalletConnect", "Session approve failed", it)
}
responder.respond()
router.back()
}
fun networksClicked() = launch {
_showNetworksBottomSheet.value = networksListFlow.first().event()
}
private suspend fun walletSelectionParams(): SelectWalletMixin.SelectionParams {
val pairingAccount = interactor.getPairingAccount(proposal.pairingTopic)
return if (pairingAccount != null) {
SelectWalletMixin.SelectionParams(
selectionAllowed = false,
initialSelection = SelectWalletMixin.InitialSelection.SpecificWallet(pairingAccount.metaId)
)
} else {
SelectWalletMixin.SelectionParams(
selectionAllowed = true,
initialSelection = SelectWalletMixin.InitialSelection.ActiveWallet
)
}
}
private fun constructSessionAlerts(metaAccount: MetaAccount, sessionProposal: WalletConnectSessionProposal): SessionAlerts {
val chains = sessionProposal.resolvedChains
val unsupportedChainsAlert = if (chains.required.hasUnknown()) {
val content = resourceManager.getString(R.string.wallet_connect_session_approve_unsupported_chains_alert, sessionProposal.dappMetadata.dAppTitle)
SessionAlerts.UnsupportedChains(content)
} else {
null
}
val chainsWithMissingAccounts = metaAccount.findMissingAccountsFor(chains.required.knownChains)
val missingAccountsAlert = if (chainsWithMissingAccounts.isNotEmpty()) {
val missingChainNames = chainsWithMissingAccounts.map { it.name }
val missingChains = resourceManager.formatListPreview(missingChainNames, maxPreviewItems = MISSING_ACCOUNTS_PREVIEW_SIZE)
val content = resourceManager.getQuantityString(
R.plurals.wallet_connect_session_approve_missing_accounts_alert,
chainsWithMissingAccounts.size,
missingChains
)
SessionAlerts.MissingAccounts(content)
} else {
null
}
return SessionAlerts(
missingAccounts = missingAccountsAlert,
unsupportedChains = unsupportedChainsAlert
)
}
private fun rejectButtonState(): Flow<DescriptiveButtonState> {
return processState.map { progressState ->
when (progressState) {
ProgressState.IDLE -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_reject))
ProgressState.REJECTING -> DescriptiveButtonState.Loading
else -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_reject))
}
}
}
private fun allowButtonState(): Flow<DescriptiveButtonState> {
return combine(processState, sessionAlerts) { progressState, sessionAlerts ->
when {
sessionAlerts.hasBlockingAlerts() -> DescriptiveButtonState.Gone
progressState == ProgressState.IDLE -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_allow))
progressState == ProgressState.CONFIRMING -> DescriptiveButtonState.Loading
else -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_allow))
}
}
}
private fun isInProgress(): Boolean {
return processState.value != ProgressState.IDLE
}
@Suppress("KotlinConstantConditions")
private fun createSessionNetworksModel(sessionChains: SessionChains): ChainListOverview {
val allKnownChains = sessionChains.allKnownChains()
val allUnknownChains = sessionChains.allUnknownChains()
val allChainsCount = allKnownChains.size + allUnknownChains.size
val value = when {
// no chains
allKnownChains.isEmpty() && allUnknownChains.isEmpty() -> resourceManager.getString(R.string.common_none)
// only unknown chains
allKnownChains.isEmpty() && allUnknownChains.isNotEmpty() -> {
resourceManager.getQuantityString(R.plurals.common_unknown_chains, allUnknownChains.size, allUnknownChains.size)
}
// single known chain
allKnownChains.size == 1 && allUnknownChains.isEmpty() -> {
allKnownChains.single().name
}
// multiple known and unknown chains
else -> {
val previewItem = allKnownChains.first().name
val othersCount = allChainsCount - 1
resourceManager.getString(R.string.common_element_and_more_format, previewItem, othersCount)
}
}
val multipleChainsRequested = allChainsCount > 1
val hasUnsupportedWarningsToShow = allUnknownChains.isNotEmpty()
val firstKnownIcon = allKnownChains.firstOrNull()?.iconOrFallback()
return ChainListOverview(
icon = firstKnownIcon?.takeUnless { multipleChainsRequested },
value = value,
label = resourceManager.getQuantityString(R.plurals.common_networks_plural, allChainsCount),
hasMoreElements = multipleChainsRequested || hasUnsupportedWarningsToShow
)
}
private fun MetaAccount.findMissingAccountsFor(chains: Collection<Chain>): List<Chain> {
return chains.filterNot(::hasAccountIn)
}
private fun constructNetworksList(sessionChains: SessionChains): List<WCNetworkListModel> {
return buildList {
addCategory(sessionChains.required, R.string.common_required)
addCategory(sessionChains.optional, R.string.common_optional)
}
}
private fun MutableList<WCNetworkListModel>.addCategory(resolvedChains: SessionChains.ResolvedChains, @StringRes categoryNameRes: Int) {
if (resolvedChains.hasAny()) {
val element = WCNetworkListModel.Label(name = resourceManager.getString(categoryNameRes), needsAdditionalSeparator = true)
add(element)
}
val knownChainsUi = resolvedChains.knownChains.map { WCNetworkListModel.Chain(mapChainToUi(it)) }
addAll(knownChainsUi)
if (resolvedChains.hasUnknown()) {
val unknownCount = resolvedChains.unknownChains.size
val unknownLabel = resourceManager.getQuantityString(R.plurals.wallet_connect_unsupported_networks_hidden, unknownCount, unknownCount)
val element = WCNetworkListModel.Label(name = unknownLabel, needsAdditionalSeparator = false)
add(element)
}
}
}
private enum class ProgressState {
IDLE, CONFIRMING, REJECTING
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.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_wallet_connect_impl.presentation.sessions.approve.WalletConnectApproveSessionFragment
@Subcomponent(
modules = [
WalletConnectApproveSessionModule::class
]
)
@ScreenScope
interface WalletConnectApproveSessionComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
): WalletConnectApproveSessionComponent
}
fun inject(fragment: WalletConnectApproveSessionFragment)
}
@@ -0,0 +1,44 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.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.feature_account_api.presenatation.mixin.selectWallet.SelectWalletMixin
import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.ApproveSessionCommunicator
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.WalletConnectApproveSessionViewModel
@Module(includes = [ViewModelModule::class])
class WalletConnectApproveSessionModule {
@Provides
@IntoMap
@ViewModelKey(WalletConnectApproveSessionViewModel::class)
fun provideViewModel(
router: WalletConnectRouter,
interactor: WalletConnectSessionInteractor,
communicator: ApproveSessionCommunicator,
resourceManager: ResourceManager,
selectWalletMixinFactory: SelectWalletMixin.Factory
): ViewModel {
return WalletConnectApproveSessionViewModel(
router = router,
interactor = interactor,
responder = communicator,
resourceManager = resourceManager,
selectWalletMixinFactory = selectWalletMixinFactory
)
}
@Provides
fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): WalletConnectApproveSessionViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(WalletConnectApproveSessionViewModel::class.java)
}
}
@@ -0,0 +1,15 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.model
class SessionAlerts(
val missingAccounts: MissingAccounts?,
val unsupportedChains: UnsupportedChains?
) {
class MissingAccounts(val alertContent: String)
class UnsupportedChains(val alertContent: String)
}
fun SessionAlerts.hasBlockingAlerts(): Boolean {
return missingAccounts != null || unsupportedChains != null
}
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.model
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainListOverview
class SessionNetworksModel(
val label: String,
val value: ChainListOverview
)
@@ -0,0 +1,10 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.view
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
sealed class WCNetworkListModel {
class Label(val name: String, val needsAdditionalSeparator: Boolean) : WCNetworkListModel()
class Chain(val chainUi: ChainUi) : WCNetworkListModel()
}
@@ -0,0 +1,76 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.view
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import androidx.core.view.updateLayoutParams
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.dp
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.feature_account_api.databinding.ItemBottomSheetChainListBinding
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
import io.novafoundation.nova.feature_account_api.view.ChainChipView
import io.novafoundation.nova.feature_wallet_connect_impl.databinding.ItemBottomSheetWcNetworksLabelBinding
class WCNetworksAdapter : GroupedListAdapter<WCNetworkListModel.Label, WCNetworkListModel.Chain>(WCNetworksDiffCallback()) {
override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder {
return WcNetworksLabelHolder(ItemBottomSheetWcNetworksLabelBinding.inflate(parent.inflater(), parent, false))
}
override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder {
return WcNetworksChainHolder(ItemBottomSheetChainListBinding.inflate(parent.inflater(), parent, false))
}
override fun bindChild(holder: GroupedListHolder, child: WCNetworkListModel.Chain) {
(holder as WcNetworksChainHolder).bind(child.chainUi)
}
override fun bindGroup(holder: GroupedListHolder, group: WCNetworkListModel.Label) {
(holder as WcNetworksLabelHolder).bind(group)
}
}
private class WcNetworksChainHolder(private val binder: ItemBottomSheetChainListBinding) : GroupedListHolder(binder.root) {
private val chainChipView = containerView as ChainChipView
fun bind(item: ChainUi) {
chainChipView.setChain(item)
}
}
private class WcNetworksLabelHolder(private val binder: ItemBottomSheetWcNetworksLabelBinding) : GroupedListHolder(binder.root) {
fun bind(item: WCNetworkListModel.Label) = with(binder.root) {
updateLayoutParams<MarginLayoutParams> {
if (item.needsAdditionalSeparator) {
setMargins(16.dp(context), 12.dp(context), 16.dp(context), 4.dp(context))
} else {
setMargins(16.dp(context), 7.dp(context), 16.dp(context), 7.dp(context))
}
}
text = item.name
}
}
private class WCNetworksDiffCallback : BaseGroupedDiffCallback<WCNetworkListModel.Label, WCNetworkListModel.Chain>(WCNetworkListModel.Label::class.java) {
override fun areGroupItemsTheSame(oldItem: WCNetworkListModel.Label, newItem: WCNetworkListModel.Label): Boolean {
return oldItem.name == newItem.name
}
override fun areGroupContentsTheSame(oldItem: WCNetworkListModel.Label, newItem: WCNetworkListModel.Label): Boolean {
return true
}
override fun areChildItemsTheSame(oldItem: WCNetworkListModel.Chain, newItem: WCNetworkListModel.Chain): Boolean {
return oldItem.chainUi.id == newItem.chainUi.id
}
override fun areChildContentsTheSame(oldItem: WCNetworkListModel.Chain, newItem: WCNetworkListModel.Chain): Boolean {
return oldItem.chainUi == newItem.chainUi
}
}
@@ -0,0 +1,24 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.view
import android.content.Context
import android.os.Bundle
import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.BaseDynamicListBottomSheet
import io.novafoundation.nova.feature_wallet_connect_impl.R
class WCNetworksBottomSheet(
context: Context,
private val data: List<WCNetworkListModel>,
) : BaseDynamicListBottomSheet(context) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.common_networks)
recyclerView.setHasFixedSize(true)
val adapter = WCNetworksAdapter()
recyclerView.adapter = adapter
adapter.submitList(data)
}
}
@@ -0,0 +1,20 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.common
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_wallet_connect_impl.R
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.SessionDappMetadata
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.dAppTitle
interface WalletConnectSessionMapper {
fun formatSessionDAppTitle(metadata: SessionDappMetadata?): String
}
class RealWalletConnectSessionMapper(
private val resourceManager: ResourceManager
) : WalletConnectSessionMapper {
override fun formatSessionDAppTitle(metadata: SessionDappMetadata?): String {
return metadata?.dAppTitle ?: resourceManager.getString(R.string.wallet_connect_unknown_dapp)
}
}
@@ -0,0 +1,79 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details
import androidx.core.os.bundleOf
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.view.setState
import io.novafoundation.nova.common.view.showValueOrHide
import io.novafoundation.nova.feature_account_api.view.showWallet
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainListBottomSheet
import io.novafoundation.nova.feature_account_api.presenatation.chain.showChainsOverview
import io.novafoundation.nova.feature_external_sign_api.presentation.dapp.showDAppIcon
import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi
import io.novafoundation.nova.feature_wallet_connect_impl.R
import io.novafoundation.nova.feature_wallet_connect_impl.databinding.FragmentWcSessionDetailsBinding
import io.novafoundation.nova.feature_wallet_connect_impl.di.WalletConnectFeatureComponent
import javax.inject.Inject
class WalletConnectSessionDetailsFragment : BaseFragment<WalletConnectSessionDetailsViewModel, FragmentWcSessionDetailsBinding>() {
companion object {
private const val KEY_PAYLOAD = "WalletConnectSessionsFragment.Payload"
fun getBundle(payload: WalletConnectSessionDetailsPayload) = bundleOf(KEY_PAYLOAD to payload)
}
override fun createBinding() = FragmentWcSessionDetailsBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
override fun initViews() {
binder.wcSessionDetailsToolbar.setHomeButtonListener { viewModel.exit() }
binder.wcSessionDetailsDisconnect.setOnClickListener { viewModel.disconnect() }
binder.wcSessionDetailsDisconnect.prepareForProgress(viewLifecycleOwner)
binder.wcSessionDetailsNetworks.setOnClickListener { viewModel.networksClicked() }
binder.wcSessionDetailsStatus.showValue(getString(R.string.common_active))
}
override fun inject() {
FeatureUtils.getFeature<WalletConnectFeatureComponent>(
requireContext(),
WalletConnectFeatureApi::class.java
)
.walletConnectSessionDetailsComponentFactory()
.create(this, argument(KEY_PAYLOAD))
.inject(this)
}
override fun subscribe(viewModel: WalletConnectSessionDetailsViewModel) {
viewModel.sessionUi.observe { sessionUi ->
binder.wcSessionDetailsWallet.showWallet(sessionUi.wallet)
binder.wcSessionDetailsDApp.showValueOrHide(sessionUi.dappUrl)
binder.wcSessionDetailsNetworks.showChainsOverview(sessionUi.networksOverview)
binder.wcSessionDetailsTitle.text = sessionUi.dappTitle
binder.wcSessionDetailsIcon.showDAppIcon(sessionUi.dappIcon, imageLoader)
with(sessionUi.status) {
binder.wcSessionDetailsStatus.setImage(icon, sizeDp = 14)
binder.wcSessionDetailsStatus.setPrimaryValueStyle(labelStyle)
binder.wcSessionDetailsStatus.showValue(label)
}
}
viewModel.showChainBottomSheet.observeEvent { chainList ->
ChainListBottomSheet(
context = requireContext(),
data = chainList
).show()
}
viewModel.disconnectButtonState.observe(binder.wcSessionDetailsDisconnect::setState)
}
}
@@ -0,0 +1,7 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class WalletConnectSessionDetailsPayload(val sessionTopic: String) : Parcelable
@@ -0,0 +1,129 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.presentation.DescriptiveButtonState
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.withFlagSet
import io.novafoundation.nova.common.view.TableCellView.FieldStyle
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
import io.novafoundation.nova.feature_account_api.presenatation.chain.formatChainListOverview
import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase
import io.novafoundation.nova.feature_wallet_connect_impl.R
import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSessionDetails
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.common.WalletConnectSessionMapper
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.model.WalletConnectSessionDetailsUi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class WalletConnectSessionDetailsViewModel(
private val router: WalletConnectRouter,
private val interactor: WalletConnectSessionInteractor,
private val resourceManager: ResourceManager,
private val walletUiUseCase: WalletUiUseCase,
private val walletConnectSessionMapper: WalletConnectSessionMapper,
private val payload: WalletConnectSessionDetailsPayload,
private val walletConnectSessionsUseCase: WalletConnectSessionsUseCase,
) : BaseViewModel() {
private val _showChainBottomSheet = MutableLiveData<Event<List<ChainUi>>>()
val showChainBottomSheet: LiveData<Event<List<ChainUi>>> = _showChainBottomSheet
private val disconnectInProgressFlow = MutableStateFlow(false)
val disconnectButtonState = disconnectInProgressFlow.map { disconnectInProgress ->
if (disconnectInProgress) {
DescriptiveButtonState.Loading
} else {
DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_disconnect))
}
}.shareInBackground()
private val sessionFlow = interactor.activeSessionFlow(payload.sessionTopic)
.shareInBackground()
val sessionUi = sessionFlow
.filterNotNull()
.map(::mapSessionDetailsToUi)
.shareInBackground()
init {
watchSessionDisconnect()
}
fun exit() {
router.back()
}
fun networksClicked() = launch {
_showChainBottomSheet.value = sessionUi.first().networks.event()
}
fun disconnect() = launch {
val sessionTopic = sessionFlow.first()?.sessionTopic ?: return@launch
disconnectInProgressFlow.withFlagSet {
interactor.disconnect(sessionTopic)
.onFailure(::showError)
}
}
private fun watchSessionDisconnect() {
sessionFlow
.distinctUntilChanged()
.onEach { if (it == null) closeSessionsScreen() }
.launchIn(this)
}
private suspend fun closeSessionsScreen() {
val numberOfActiveSessions = walletConnectSessionsUseCase.activeSessionsNumber()
if (numberOfActiveSessions > 0) {
router.back()
} else {
router.backToSettings()
}
}
private suspend fun mapSessionDetailsToUi(session: WalletConnectSessionDetails): WalletConnectSessionDetailsUi {
val chainUis = session.chains.map(::mapChainToUi)
return WalletConnectSessionDetailsUi(
dappTitle = walletConnectSessionMapper.formatSessionDAppTitle(session.dappMetadata),
dappUrl = session.dappMetadata?.dAppUrl,
dappIcon = session.dappMetadata?.icon,
networksOverview = resourceManager.formatChainListOverview(chainUis),
networks = chainUis,
wallet = walletUiUseCase.walletUiFor(session.connectedMetaAccount),
status = mapSessionStatusToUi(session.status)
)
}
private fun mapSessionStatusToUi(status: WalletConnectSessionDetails.SessionStatus): WalletConnectSessionDetailsUi.SessionStatus {
return when (status) {
WalletConnectSessionDetails.SessionStatus.ACTIVE -> WalletConnectSessionDetailsUi.SessionStatus(
label = resourceManager.getString(R.string.common_active),
labelStyle = FieldStyle.POSITIVE,
icon = R.drawable.ic_indicator_positive_pulse
)
WalletConnectSessionDetails.SessionStatus.EXPIRED -> WalletConnectSessionDetailsUi.SessionStatus(
label = resourceManager.getString(R.string.common_expired),
labelStyle = FieldStyle.SECONDARY,
icon = R.drawable.ic_indicator_inactive_pulse
)
}
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.WalletConnectSessionDetailsFragment
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.WalletConnectSessionDetailsPayload
@Subcomponent(
modules = [
WalletConnectSessionDetailsModule::class
]
)
@ScreenScope
interface WalletConnectSessionDetailsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: WalletConnectSessionDetailsPayload,
): WalletConnectSessionDetailsComponent
}
fun inject(fragment: WalletConnectSessionDetailsFragment)
}
@@ -0,0 +1,50 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase
import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.common.WalletConnectSessionMapper
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.WalletConnectSessionDetailsPayload
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.WalletConnectSessionDetailsViewModel
@Module(includes = [ViewModelModule::class])
class WalletConnectSessionDetailsModule {
@Provides
@IntoMap
@ViewModelKey(WalletConnectSessionDetailsViewModel::class)
fun provideViewModel(
router: WalletConnectRouter,
walletConnectSessionMapper: WalletConnectSessionMapper,
interactor: WalletConnectSessionInteractor,
resourceManager: ResourceManager,
walletUiUseCase: WalletUiUseCase,
payload: WalletConnectSessionDetailsPayload,
walletConnectSessionsUseCase: WalletConnectSessionsUseCase
): ViewModel {
return WalletConnectSessionDetailsViewModel(
router = router,
interactor = interactor,
resourceManager = resourceManager,
walletUiUseCase = walletUiUseCase,
walletConnectSessionMapper = walletConnectSessionMapper,
payload = payload,
walletConnectSessionsUseCase = walletConnectSessionsUseCase
)
}
@Provides
fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): WalletConnectSessionDetailsViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(WalletConnectSessionDetailsViewModel::class.java)
}
}
@@ -0,0 +1,24 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.model
import androidx.annotation.DrawableRes
import io.novafoundation.nova.common.view.TableCellView
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainListOverview
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
class WalletConnectSessionDetailsUi(
val dappTitle: String,
val dappUrl: String?,
val dappIcon: String?,
val networksOverview: ChainListOverview,
val networks: List<ChainUi>,
val wallet: WalletModel,
val status: SessionStatus
) {
class SessionStatus(
val label: String,
val labelStyle: TableCellView.FieldStyle,
@DrawableRes val icon: Int
)
}
@@ -0,0 +1,7 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class WalletConnectSessionsPayload(val metaId: Long?) : Parcelable
@@ -0,0 +1,59 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import io.novafoundation.nova.common.list.BaseListAdapter
import io.novafoundation.nova.common.list.BaseViewHolder
import io.novafoundation.nova.feature_dapp_api.presentation.view.DAppView
import io.novafoundation.nova.feature_wallet_connect_impl.R
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.model.SessionListModel
class WalletConnectSessionsAdapter(
private val handler: Handler
) : BaseListAdapter<SessionListModel, SessionHolder>(SessionDiffCallback()) {
interface Handler {
fun itemClicked(item: SessionListModel)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SessionHolder {
return SessionHolder(DAppView.createUsingMathParentWidth(parent.context), handler)
}
override fun onBindViewHolder(holder: SessionHolder, position: Int) {
holder.bind(getItem(position))
}
}
class SessionHolder(
private val dAppView: DAppView,
private val itemHandler: WalletConnectSessionsAdapter.Handler,
) : BaseViewHolder(dAppView) {
override fun unbind() {
dAppView.clearIcon()
}
fun bind(item: SessionListModel) = with(dAppView) {
setIconUrl(item.iconUrl)
setTitle(item.dappTitle)
setSubtitle(item.walletModel.name)
enableSubtitleIcon().setImageDrawable(item.walletModel.icon)
setActionResource(iconRes = R.drawable.ic_chevron_right, colorRes = R.color.icon_secondary)
setOnClickListener { itemHandler.itemClicked(item) }
}
}
class SessionDiffCallback : DiffUtil.ItemCallback<SessionListModel>() {
override fun areItemsTheSame(oldItem: SessionListModel, newItem: SessionListModel): Boolean {
return oldItem.sessionTopic == newItem.sessionTopic
}
override fun areContentsTheSame(oldItem: SessionListModel, newItem: SessionListModel): Boolean {
return oldItem == newItem
}
}
@@ -0,0 +1,79 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list
import android.util.Log
import com.walletconnect.web3.wallet.client.Wallet
import com.walletconnect.web3.wallet.client.Web3Wallet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.shareIn
sealed class WalletConnectSessionsEvent {
data class SessionProposal(val proposal: Wallet.Model.SessionProposal) : WalletConnectSessionsEvent()
data class SessionRequest(val request: Wallet.Model.SessionRequest) : WalletConnectSessionsEvent()
data class SessionSettlement(val settlement: Wallet.Model.SettledSessionResponse) : WalletConnectSessionsEvent()
data class SessionDeleted(val delete: Wallet.Model.SessionDelete) : WalletConnectSessionsEvent()
}
fun Web3Wallet.sessionEventsFlow(scope: CoroutineScope): Flow<WalletConnectSessionsEvent> {
return callbackFlow {
setWalletDelegate(object : Web3Wallet.WalletDelegate {
override fun onAuthRequest(authRequest: Wallet.Model.AuthRequest, verifyContext: Wallet.Model.VerifyContext) {
Log.d("WalletConnect", "Auth request: $authRequest")
}
override fun onConnectionStateChange(state: Wallet.Model.ConnectionState) {
Log.d("WalletConnect", "on connection state change: $state")
}
override fun onError(error: Wallet.Model.Error) {
Log.e("WalletConnect", "Wallet Connect error", error.throwable)
}
override fun onProposalExpired(proposal: Wallet.Model.ExpiredProposal) {
Log.d("WalletConnect", "Proposal expired: $proposal")
}
override fun onRequestExpired(request: Wallet.Model.ExpiredRequest) {
Log.d("WalletConnect", "Request expired: $request")
}
override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) {
Log.d("WalletConnect", "on session delete: $sessionDelete")
channel.trySend(WalletConnectSessionsEvent.SessionDeleted(sessionDelete))
}
override fun onSessionExtend(session: Wallet.Model.Session) {
Log.d("WalletConnect", "On session extend: $session")
}
override fun onSessionProposal(sessionProposal: Wallet.Model.SessionProposal, verifyContext: Wallet.Model.VerifyContext) {
Log.d("WalletConnect", "on session proposal: $sessionProposal")
channel.trySend(WalletConnectSessionsEvent.SessionProposal(sessionProposal))
}
override fun onSessionRequest(sessionRequest: Wallet.Model.SessionRequest, verifyContext: Wallet.Model.VerifyContext) {
Log.d("WalletConnect", "on session request: $sessionRequest")
channel.trySend(WalletConnectSessionsEvent.SessionRequest(sessionRequest))
}
override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) {
Log.d("WalletConnect", "on session settled: $settleSessionResponse")
channel.trySend(WalletConnectSessionsEvent.SessionSettlement(settleSessionResponse))
}
override fun onSessionUpdateResponse(sessionUpdateResponse: Wallet.Model.SessionUpdateResponse) {
Log.d("WalletConnect", "on session update: $sessionUpdateResponse")
}
})
awaitClose { }
}.shareIn(scope, SharingStarted.Eagerly)
}
@@ -0,0 +1,62 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list
import androidx.core.os.bundleOf
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.setVisible
import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi
import io.novafoundation.nova.feature_wallet_connect_impl.databinding.FragmentWcSessionsBinding
import io.novafoundation.nova.feature_wallet_connect_impl.di.WalletConnectFeatureComponent
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.model.SessionListModel
import javax.inject.Inject
class WalletConnectSessionsFragment : BaseFragment<WalletConnectSessionsViewModel, FragmentWcSessionsBinding>(), WalletConnectSessionsAdapter.Handler {
companion object {
private const val KEY_PAYLOAD = "WalletConnectSessionsFragment.Payload"
fun getBundle(payload: WalletConnectSessionsPayload) = bundleOf(KEY_PAYLOAD to payload)
}
override fun createBinding() = FragmentWcSessionsBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
private val sessionsAdapter = WalletConnectSessionsAdapter(handler = this)
override fun initViews() {
binder.wcSessionsToolbar.setHomeButtonListener { viewModel.exit() }
binder.wcSessionsConnectionsList.setHasFixedSize(true)
binder.wcSessionsConnectionsList.adapter = sessionsAdapter
binder.wcSessionsNewConnection.setOnClickListener { viewModel.newSessionClicked() }
}
override fun inject() {
FeatureUtils.getFeature<WalletConnectFeatureComponent>(
requireContext(),
WalletConnectFeatureApi::class.java
)
.walletConnectSessionsComponentFactory()
.create(this, argument(KEY_PAYLOAD))
.inject(this)
}
override fun subscribe(viewModel: WalletConnectSessionsViewModel) {
viewModel.sessionsFlow.observe { sessions ->
sessionsAdapter.submitList(sessions)
binder.wcSessionsConnectionsList.setVisible(sessions.isNotEmpty())
binder.wcSessionsConnectionsPlaceholder.setVisible(sessions.isEmpty())
}
}
override fun itemClicked(item: SessionListModel) {
viewModel.sessionClicked(item)
}
}
@@ -0,0 +1,47 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter
import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSession
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.common.WalletConnectSessionMapper
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.WalletConnectSessionDetailsPayload
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.model.SessionListModel
class WalletConnectSessionsViewModel(
private val router: WalletConnectRouter,
private val interactor: WalletConnectSessionInteractor,
private val walletUiUseCase: WalletUiUseCase,
private val walletConnectSessionMapper: WalletConnectSessionMapper,
private val walletConnectSessionsPayload: WalletConnectSessionsPayload
) : BaseViewModel() {
val sessionsFlow = interactor.activeSessionsFlow(walletConnectSessionsPayload.metaId)
.mapList(::mapSessionToUi)
.shareInBackground()
fun exit() {
router.back()
}
fun newSessionClicked() {
router.openScanPairingQrCode()
}
fun sessionClicked(item: SessionListModel) {
router.openSessionDetails(WalletConnectSessionDetailsPayload(item.sessionTopic))
}
private suspend fun mapSessionToUi(session: WalletConnectSession): SessionListModel {
val title = walletConnectSessionMapper.formatSessionDAppTitle(session.dappMetadata)
return SessionListModel(
dappTitle = title,
walletModel = walletUiUseCase.walletUiFor(session.connectedMetaAccount),
iconUrl = session.dappMetadata?.icon,
sessionTopic = session.sessionTopic
)
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsFragment
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsPayload
@Subcomponent(
modules = [
WalletConnectSessionsModule::class
]
)
@ScreenScope
interface WalletConnectSessionsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: WalletConnectSessionsPayload
): WalletConnectSessionsComponent
}
fun inject(fragment: WalletConnectSessionsFragment)
}
@@ -0,0 +1,44 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter
import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.common.WalletConnectSessionMapper
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsPayload
import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsViewModel
@Module(includes = [ViewModelModule::class])
class WalletConnectSessionsModule {
@Provides
@IntoMap
@ViewModelKey(WalletConnectSessionsViewModel::class)
fun provideViewModel(
router: WalletConnectRouter,
interactor: WalletConnectSessionInteractor,
walletUiUseCase: WalletUiUseCase,
walletConnectSessionMapper: WalletConnectSessionMapper,
walletConnectSessionsPayload: WalletConnectSessionsPayload
): ViewModel {
return WalletConnectSessionsViewModel(
router = router,
interactor = interactor,
walletUiUseCase = walletUiUseCase,
walletConnectSessionMapper = walletConnectSessionMapper,
walletConnectSessionsPayload = walletConnectSessionsPayload
)
}
@Provides
fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): WalletConnectSessionsViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(WalletConnectSessionsViewModel::class.java)
}
}
@@ -0,0 +1,10 @@
package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.model
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel
data class SessionListModel(
val dappTitle: String,
val walletModel: WalletModel,
val iconUrl: String?,
val sessionTopic: String,
)
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<io.novafoundation.nova.common.presentation.scan.ScanView
android:id="@+id/walletConnectScan"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:subTitle="@string/wallet_connect_v2"
app:title="@string/common_scan_qr_code" />
<io.novafoundation.nova.common.view.Toolbar
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false"
android:id="@+id/walletConnectScanToolbar"
app:contentBackground="@android:color/transparent" />
</FrameLayout>
@@ -0,0 +1,187 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:background="@color/secondary_screen_background">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/wcApproveSessionToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false"
app:homeButtonIcon="@drawable/ic_close"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:titleText="@string/wallet_connect_title" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/wcApproveSessionNovaIcon"
style="@style/Widget.Nova.Icon.Big"
android:layout_marginTop="24dp"
android:padding="16dp"
android:src="@drawable/ic_pezkuwi_logo"
app:layout_constraintEnd_toStartOf="@+id/wcApproveSessionNovaArrow"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/wcApproveSessionNovaArrow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:src="@drawable/ic_bidirectonal"
app:layout_constraintBottom_toBottomOf="@+id/wcApproveSessionNovaIcon"
app:layout_constraintEnd_toStartOf="@+id/wcApproveSessionIcon"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/wcApproveSessionNovaIcon"
app:layout_constraintTop_toTopOf="@+id/wcApproveSessionNovaIcon"
app:tint="@color/icon_secondary" />
<ImageView
android:id="@+id/wcApproveSessionIcon"
style="@style/Widget.Nova.Icon.Big"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/wcApproveSessionNovaArrow"
app:layout_constraintTop_toTopOf="@+id/wcApproveSessionNovaIcon"
tools:src="@drawable/ic_earth" />
<TextView
android:id="@+id/wcApproveSessionTitle"
style="@style/TextAppearance.NovaFoundation.SemiBold.Title3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:textColor="@color/text_primary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/wcApproveSessionNovaIcon"
tools:text="Allow “Polkadot.js” to access your account addresses?" />
<TextView
android:id="@+id/wcApproveSessionSubTitle"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:text="@string/dapp_confirm_authorize_subtitle"
android:textColor="@color/text_secondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/wcApproveSessionTitle" />
<io.novafoundation.nova.common.view.TableView
android:id="@+id/wcApproveSessionRequestSession"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/wcApproveSessionSubTitle">
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/wcApproveSessionDApp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/dapp_dapp" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/wcApproveSessionNetworks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/common_networks" />
</io.novafoundation.nova.common.view.TableView>
<io.novafoundation.nova.common.view.AlertView
android:id="@+id/wcApproveSessionChainsAlert"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/wcApproveSessionRequestSession"
tools:text="Some of the required networks requested by 1inch are not supported in Nova Wallet" />
<io.novafoundation.nova.feature_account_api.view.AccountView
android:id="@+id/wcApproveSessionWallet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
app:actionIcon="@drawable/ic_chevron_right"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/wcApproveSessionChainsAlert"
app:showBackground="true" />
<io.novafoundation.nova.common.view.AlertView
android:id="@+id/wcApproveSessionAccountsAlert"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/wcApproveSessionWallet"
tools:text="Ethereum account is missing. Add account to the wallet in Settings" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="horizontal">
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/wcApproveSessionReject"
style="@style/Widget.Nova.Button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/common_reject"
app:appearance="secondary" />
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/wcApproveSessionAllow"
style="@style/Widget.Nova.Button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:text="@string/common_allow"
app:appearance="primary" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_horizontal"
android:orientation="vertical"
tools:background="@color/secondary_screen_background">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/wcSessionDetailsToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false"
app:titleText="@string/wallet_connect_title" />
<ImageView
android:id="@+id/wcSessionDetailsIcon"
style="@style/Widget.Nova.Icon.Big"
android:layout_marginTop="32dp"
tools:src="@drawable/ic_earth" />
<TextView
android:id="@+id/wcSessionDetailsTitle"
style="@style/TextAppearance.NovaFoundation.SemiBold.Title3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:textColor="@color/text_primary"
tools:text="1inch dApp" />
<io.novafoundation.nova.common.view.TableView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="24dp">
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/wcSessionDetailsWallet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/tabbar_wallet_title" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/wcSessionDetailsDApp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/dapp_dapp" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/wcSessionDetailsNetworks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/common_networks" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/wcSessionDetailsStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:primaryValueStyle="positive"
app:title="@string/common_status" />
</io.novafoundation.nova.common.view.TableView>
<Space
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/wcSessionDetailsDisconnect"
style="@style/Widget.Nova.Button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="@string/common_disconnect"
app:appearance="primaryNegative" />
</LinearLayout>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:background="@color/secondary_screen_background">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/wcSessionsToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false"
app:titleText="@string/wallet_connect_title" />
<io.novafoundation.nova.common.view.PlaceholderView
android:id="@+id/wcSessionsConnectionsPlaceholder"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:text="@string/wallet_connect_sessions_placeholder"
android:visibility="gone"
app:image="@drawable/ic_placeholder"
app:placeholderBackgroundStyle="no_background"
tools:visibility="visible" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/wcSessionsConnectionsList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingTop="8dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/wcSessionsNewConnection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:iconSrc="@drawable/ic_qr_scan"
android:text="@string/wallet_connect_new_connection" />
</LinearLayout>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
xmlns:tools="http://schemas.android.com/tools"
tools:background="@color/secondary_screen_background"
android:textColor="@color/text_secondary"
tools:text="@string/common_required"
android:layout_marginStart="16dp"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:id="@+id/itemWcNetworksLabel"
android:layout_height="wrap_content" />