Initial commit: Pezkuwi Wallet Android

Complete rebrand of Nova Wallet for Pezkuwichain ecosystem.

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

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

Based on Nova Wallet by Novasama Technologies GmbH
© Dijital Kurdistan Tech Institute 2026
This commit is contained in:
2026-01-23 01:31:12 +03:00
commit 31c8c5995f
7621 changed files with 425838 additions and 0 deletions
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,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" />