Initial commit: Pezkuwi Wallet Android

Security hardened release:
- Code obfuscation enabled (minifyEnabled=true, shrinkResources=true)
- Sensitive files excluded (google-services.json, keystores)
- Branch.io key moved to BuildConfig placeholder
- Updated dependencies: OkHttp 4.12.0, Gson 2.10.1, BouncyCastle 1.77
- Comprehensive ProGuard rules for crypto wallet
- Navigation 2.7.7, Lifecycle 2.7.0, ConstraintLayout 2.1.4
This commit is contained in:
2026-02-12 05:19:41 +03:00
commit a294aa1a6b
7687 changed files with 441811 additions and 0 deletions
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,18 @@
package io.novafoundation.nova.feature_dapp_impl.data.mappers
import io.novafoundation.nova.core_db.model.BrowserHostSettingsLocal
import io.novafoundation.nova.feature_dapp_api.data.model.BrowserHostSettings
fun mapBrowserHostSettingsToLocal(settings: BrowserHostSettings): BrowserHostSettingsLocal {
return BrowserHostSettingsLocal(
settings.hostUrl,
settings.isDesktopModeEnabled
)
}
fun mapBrowserHostSettingsFromLocal(settings: BrowserHostSettingsLocal): BrowserHostSettings {
return BrowserHostSettings(
settings.hostUrl,
settings.isDesktopModeEnabled
)
}
@@ -0,0 +1,33 @@
package io.novafoundation.nova.feature_dapp_impl.data.mappers
import io.novafoundation.nova.common.utils.Urls
import io.novafoundation.nova.feature_dapp_api.data.model.DappCatalog
import io.novafoundation.nova.feature_dapp_api.data.model.DappCategory
import io.novafoundation.nova.feature_dapp_api.data.model.DappMetadata
import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataResponse
fun mapDAppMetadataResponseToDAppMetadatas(
response: DappMetadataResponse
): DappCatalog {
val categories = response.categories.map {
DappCategory(
iconUrl = it.icon,
name = it.name,
id = it.id
)
}
val categoriesAssociatedById = categories.associateBy { it.id }
val metadata = response.dapps.map {
DappMetadata(
name = it.name,
iconLink = it.icon,
url = it.url,
baseUrl = Urls.normalizeUrl(it.url),
categories = it.categories.mapNotNullTo(mutableSetOf(), categoriesAssociatedById::get),
)
}
return DappCatalog(response.popular.map { it.url }, categories, metadata)
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_dapp_impl.data.mappers
import io.novafoundation.nova.core_db.model.FavouriteDAppLocal
import io.novafoundation.nova.feature_dapp_impl.data.model.FavouriteDApp
fun mapFavouriteDAppLocalToFavouriteDApp(favouriteDAppLocal: FavouriteDAppLocal): FavouriteDApp {
return with(favouriteDAppLocal) {
FavouriteDApp(
url = url,
label = label,
icon = icon,
orderingIndex = orderingIndex
)
}
}
fun mapFavouriteDAppToFavouriteDAppLocal(favouriteDApp: FavouriteDApp): FavouriteDAppLocal {
return with(favouriteDApp) {
FavouriteDAppLocal(
url = url,
label = label,
icon = icon,
orderingIndex = orderingIndex
)
}
}
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_dapp_impl.data.model
data class FavouriteDApp(
val url: String,
val label: String,
val icon: String?,
val orderingIndex: Int
)
@@ -0,0 +1,10 @@
package io.novafoundation.nova.feature_dapp_impl.data.network.metadata
import retrofit2.http.GET
import retrofit2.http.Url
interface DappMetadataApi {
@GET
suspend fun getParachainMetadata(@Url url: String): DappMetadataResponse
}
@@ -0,0 +1,23 @@
package io.novafoundation.nova.feature_dapp_impl.data.network.metadata
class DappMetadataResponse(
val popular: List<DappPopularRemote>,
val categories: List<DappCategoryRemote>,
val dapps: List<DappMetadataRemote>
)
class DappMetadataRemote(
val name: String,
val icon: String,
val url: String,
val categories: List<String>,
val desktopOnly: Boolean?
)
class DappCategoryRemote(
val icon: String?,
val name: String,
val id: String
)
class DappPopularRemote(val url: String)
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_dapp_impl.data.network.phishing
import retrofit2.http.GET
interface PhishingSitesApi {
@GET("https://raw.githubusercontent.com/polkadot-js/phishing/master/all.json")
suspend fun getPhishingSites(): PhishingSitesRemote
}
@@ -0,0 +1,5 @@
package io.novafoundation.nova.feature_dapp_impl.data.network.phishing
class PhishingSitesRemote(
val deny: List<String>
)
@@ -0,0 +1,62 @@
package io.novafoundation.nova.feature_dapp_impl.data.phisning
import io.novafoundation.nova.common.utils.Urls
import io.novafoundation.nova.core_db.dao.PhishingSitesDao
interface PhishingDetectingService {
suspend fun isPhishing(url: String): Boolean
}
class CompoundPhishingDetectingService(
private val services: List<PhishingDetectingService>
) : PhishingDetectingService {
override suspend fun isPhishing(url: String): Boolean {
return services.any { it.isPhishing(url) }
}
}
class BlackListPhishingDetectingService(
private val phishingSitesDao: PhishingSitesDao,
) : PhishingDetectingService {
override suspend fun isPhishing(url: String): Boolean {
val host = Urls.hostOf(url)
val hostSuffixes = extractAllPossibleSubDomains(host)
return phishingSitesDao.isPhishing(hostSuffixes)
}
private fun extractAllPossibleSubDomains(host: String): List<String> {
val separator = "."
val segments = host.split(separator)
val suffixes = (2..segments.size).map { suffixLength ->
segments.takeLast(suffixLength).joinToString(separator = ".")
}
return suffixes
}
}
class DomainListPhishingDetectingService(
private val blackListDomains: List<String> // top
) : PhishingDetectingService {
override suspend fun isPhishing(url: String): Boolean {
val host = Urls.hostOf(url)
val urlTopLevelDomain = extractTopLevelDomain(host)
return blackListDomains.any { urlTopLevelDomain == it }
}
private fun extractTopLevelDomain(host: String): String {
val separator = "."
val segments = host.split(separator)
return segments.last()
}
}
@@ -0,0 +1,34 @@
package io.novafoundation.nova.feature_dapp_impl.data.repository
import com.google.gson.Gson
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.fromJson
import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskChain
import javax.inject.Inject
interface DefaultMetamaskChainRepository {
fun getDefaultMetamaskChain(): MetamaskChain?
fun saveDefaultMetamaskChain(chain: MetamaskChain)
}
private const val PREFERENCES_KEY = "RealDefaultMetamaskChainRepository.DefaultMetamaskChain"
@FeatureScope
class RealDefaultMetamaskChainRepository @Inject constructor(
private val preferences: Preferences,
private val gson: Gson,
) : DefaultMetamaskChainRepository {
override fun getDefaultMetamaskChain(): MetamaskChain? {
val raw = preferences.getString(PREFERENCES_KEY) ?: return null
return runCatching { gson.fromJson<MetamaskChain>(raw) }.getOrNull()
}
override fun saveDefaultMetamaskChain(chain: MetamaskChain) {
val raw = gson.toJson(chain)
preferences.putString(PREFERENCES_KEY, raw)
}
}
@@ -0,0 +1,70 @@
package io.novafoundation.nova.feature_dapp_impl.data.repository
import io.novafoundation.nova.common.utils.CollectionDiffer
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.core_db.dao.FavouriteDAppsDao
import io.novafoundation.nova.feature_dapp_impl.data.mappers.mapFavouriteDAppLocalToFavouriteDApp
import io.novafoundation.nova.feature_dapp_impl.data.mappers.mapFavouriteDAppToFavouriteDAppLocal
import io.novafoundation.nova.feature_dapp_impl.data.model.FavouriteDApp
import kotlinx.coroutines.flow.Flow
interface FavouritesDAppRepository {
fun observeFavourites(): Flow<List<FavouriteDApp>>
suspend fun getFavourites(): List<FavouriteDApp>
suspend fun addFavourite(favouriteDApp: FavouriteDApp)
fun observeIsFavourite(url: String): Flow<Boolean>
suspend fun removeFavourite(dAppUrl: String)
suspend fun updateFavoriteDapps(favoriteDapps: List<FavouriteDApp>)
suspend fun getNextOrderingIndex(): Int
}
class DbFavouritesDAppRepository(
private val favouriteDAppsDao: FavouriteDAppsDao
) : FavouritesDAppRepository {
override fun observeFavourites(): Flow<List<FavouriteDApp>> {
return favouriteDAppsDao.observeFavouriteDApps()
.mapList(::mapFavouriteDAppLocalToFavouriteDApp)
}
override suspend fun getFavourites(): List<FavouriteDApp> {
return favouriteDAppsDao.getFavouriteDApps()
.map(::mapFavouriteDAppLocalToFavouriteDApp)
}
override suspend fun addFavourite(favouriteDApp: FavouriteDApp) {
val local = mapFavouriteDAppToFavouriteDAppLocal(favouriteDApp)
favouriteDAppsDao.insertFavouriteDApp(local)
}
override fun observeIsFavourite(url: String): Flow<Boolean> {
return favouriteDAppsDao.observeIsFavourite(url)
}
override suspend fun removeFavourite(dAppUrl: String) {
favouriteDAppsDao.deleteFavouriteDApp(dAppUrl)
}
override suspend fun updateFavoriteDapps(favoriteDapps: List<FavouriteDApp>) {
val newDapps = favoriteDapps.map { mapFavouriteDAppToFavouriteDAppLocal(it) }
val currentDapps = favouriteDAppsDao.getFavouriteDApps()
val diff = CollectionDiffer.findDiff(newDapps, currentDapps, false)
favouriteDAppsDao.updateFavourites(diff.updated)
}
override suspend fun getNextOrderingIndex(): Int {
return try {
favouriteDAppsDao.getMaxOrderingIndex() + 1
} catch (e: NullPointerException) { // For case we don't have added favorite dapps
0
}
}
}
@@ -0,0 +1,32 @@
package io.novafoundation.nova.feature_dapp_impl.data.repository
import io.novafoundation.nova.common.utils.retryUntilDone
import io.novafoundation.nova.core_db.dao.PhishingSitesDao
import io.novafoundation.nova.core_db.model.PhishingSiteLocal
import io.novafoundation.nova.feature_dapp_impl.data.network.phishing.PhishingSitesApi
import io.novafoundation.nova.feature_dapp_impl.data.phisning.PhishingDetectingService
interface PhishingSitesRepository {
suspend fun syncPhishingSites()
suspend fun isPhishing(url: String): Boolean
}
class PhishingSitesRepositoryImpl(
private val phishingSitesDao: PhishingSitesDao,
private val phishingSitesApi: PhishingSitesApi,
private val phishingDetectingService: PhishingDetectingService
) : PhishingSitesRepository {
override suspend fun syncPhishingSites() {
val remotePhishingSites = retryUntilDone { phishingSitesApi.getPhishingSites() }
val toInsert = remotePhishingSites.deny.map(::PhishingSiteLocal)
phishingSitesDao.updatePhishingSites(toInsert)
}
override suspend fun isPhishing(url: String): Boolean {
return phishingDetectingService.isPhishing(url)
}
}
@@ -0,0 +1,23 @@
package io.novafoundation.nova.feature_dapp_impl.data.repository
import io.novafoundation.nova.common.utils.Urls
import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao
import io.novafoundation.nova.feature_dapp_api.data.model.BrowserHostSettings
import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserHostSettingsRepository
import io.novafoundation.nova.feature_dapp_impl.data.mappers.mapBrowserHostSettingsFromLocal
import io.novafoundation.nova.feature_dapp_impl.data.mappers.mapBrowserHostSettingsToLocal
class RealBrowserHostSettingsRepository(
private val browserHostSettingsDao: BrowserHostSettingsDao
) : BrowserHostSettingsRepository {
override suspend fun getBrowserHostSettings(url: String): BrowserHostSettings? {
val hostUrl = Urls.normalizeUrl(url)
val localSettings = browserHostSettingsDao.getBrowserHostSettings(hostUrl)
return localSettings?.let { mapBrowserHostSettingsFromLocal(it) }
}
override suspend fun saveBrowserHostSettings(settings: BrowserHostSettings) {
val localSettings = mapBrowserHostSettingsToLocal(settings)
browserHostSettingsDao.insertBrowserHostSettings(localSettings)
}
}
@@ -0,0 +1,78 @@
package io.novafoundation.nova.feature_dapp_impl.data.repository
import io.novafoundation.nova.common.utils.CollectionDiffer
import io.novafoundation.nova.common.utils.Urls
import io.novafoundation.nova.common.utils.retryUntilDone
import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao
import io.novafoundation.nova.core_db.model.BrowserHostSettingsLocal
import io.novafoundation.nova.feature_dapp_api.data.model.DappCatalog
import io.novafoundation.nova.feature_dapp_api.data.model.DappMetadata
import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_impl.data.mappers.mapDAppMetadataResponseToDAppMetadatas
import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataApi
import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataRemote
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
class RealDAppMetadataRepository(
private val dappMetadataApi: DappMetadataApi,
private val remoteApiUrl: String,
private val browserHostSettingsDao: BrowserHostSettingsDao
) : DAppMetadataRepository {
private val dappMetadatasFlow = MutableSharedFlow<DappCatalog>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override suspend fun isDAppsSynced(): Boolean {
return dappMetadatasFlow.replayCache.isNotEmpty()
}
override suspend fun syncDAppMetadatas() {
val response = retryUntilDone { dappMetadataApi.getParachainMetadata(remoteApiUrl) }
val dappMetadatas = mapDAppMetadataResponseToDAppMetadatas(response)
syncHostSettings(response.dapps)
dappMetadatasFlow.emit(dappMetadatas)
}
override suspend fun syncAndGetDapp(baseUrl: String): DappMetadata? {
syncDAppMetadatas()
return getDAppMetadata(baseUrl)
}
override suspend fun getDAppMetadata(baseUrl: String): DappMetadata? {
return dappMetadatasFlow.first()
.dApps
.find { it.baseUrl == baseUrl }
}
override suspend fun findDAppMetadataByExactUrlMatch(fullUrl: String): DappMetadata? {
return dappMetadatasFlow.first()
.dApps
.find { it.url == fullUrl }
}
override suspend fun findDAppMetadatasByBaseUrlMatch(baseUrl: String): List<DappMetadata> {
return dappMetadatasFlow.first()
.dApps
.filter { it.baseUrl == baseUrl }
}
override suspend fun getDAppCatalog(): DappCatalog {
return dappMetadatasFlow.first()
}
override fun observeDAppCatalog(): Flow<DappCatalog> {
return dappMetadatasFlow
}
private suspend fun syncHostSettings(dappMetadatas: List<DappMetadataRemote>) {
val oldSettings = browserHostSettingsDao.getBrowserAllHostSettings()
val newSettings = dappMetadatas
.filter { it.desktopOnly != null }
.map { BrowserHostSettingsLocal(Urls.normalizeUrl(it.url), it.desktopOnly!!) }
val differ = CollectionDiffer.findDiff(newSettings, oldSettings, false)
browserHostSettingsDao.insertBrowserHostSettings(differ.added)
}
}
@@ -0,0 +1,21 @@
package io.novafoundation.nova.feature_dapp_impl.data.repository.tabs
import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserTabExternalRepository
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTab
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.PageSnapshot
import kotlinx.coroutines.flow.Flow
interface BrowserTabInternalRepository : BrowserTabExternalRepository {
suspend fun saveTab(tab: BrowserTab)
suspend fun removeTab(tabId: String)
suspend fun savePageSnapshot(tabId: String, snapshot: PageSnapshot)
fun observeTabs(metaId: Long): Flow<List<BrowserTab>>
suspend fun changeCurrentUrl(tabId: String, url: String)
suspend fun changeKnownDAppMetadata(tabId: String, dappIconUrl: String?)
}
@@ -0,0 +1,89 @@
package io.novafoundation.nova.feature_dapp_impl.data.repository.tabs
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.core_db.dao.BrowserTabsDao
import io.novafoundation.nova.core_db.model.BrowserTabLocal
import io.novafoundation.nova.feature_dapp_api.data.model.SimpleTabModel
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTab
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.PageSnapshot
import java.util.Date
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
class RealBrowserTabRepository(
private val browserTabsDao: BrowserTabsDao
) : BrowserTabInternalRepository {
override suspend fun saveTab(tab: BrowserTab) {
browserTabsDao.insertTab(tab.toLocal())
}
override suspend fun removeTab(tabId: String) {
browserTabsDao.removeTab(tabId)
}
override fun observeTabsWithNames(metaId: Long): Flow<List<SimpleTabModel>> {
return browserTabsDao.observeTabsByMetaId(metaId)
.mapList {
SimpleTabModel(it.id, it.pageName, it.knownDAppMetadata?.iconLink, it.pageIconPath)
}
}
override suspend fun removeTabsForMetaAccount(metaId: Long): List<String> {
return withContext(Dispatchers.Default) {
browserTabsDao.removeTabsByMetaId(metaId)
}
}
override suspend fun savePageSnapshot(tabId: String, snapshot: PageSnapshot) {
browserTabsDao.updatePageSnapshot(
tabId = tabId,
pageName = snapshot.pageName,
pageIconPath = snapshot.pageIconPath,
pagePicturePath = snapshot.pagePicturePath
)
}
override fun observeTabs(metaId: Long): Flow<List<BrowserTab>> {
return browserTabsDao.observeTabsByMetaId(metaId).mapList { tab ->
tab.fromLocal()
}
}
override suspend fun changeCurrentUrl(tabId: String, url: String) {
withContext(Dispatchers.Default) { browserTabsDao.updateCurrentUrl(tabId, url) }
}
override suspend fun changeKnownDAppMetadata(tabId: String, dappIconUrl: String?) {
withContext(Dispatchers.Default) { browserTabsDao.updateKnownDAppMetadata(tabId, dappIconUrl) }
}
}
private fun BrowserTabLocal.fromLocal(): BrowserTab {
return BrowserTab(
id = id,
metaId = metaId,
currentUrl = currentUrl,
pageSnapshot = PageSnapshot(
pageName = pageName,
pageIconPath = pageIconPath,
pagePicturePath = pagePicturePath
),
knownDAppMetadata = knownDAppMetadata?.let { BrowserTab.KnownDAppMetadata(it.iconLink) },
creationTime = Date(creationTime),
)
}
private fun BrowserTab.toLocal(): BrowserTabLocal {
return BrowserTabLocal(
id = id,
metaId = metaId,
currentUrl = currentUrl,
creationTime = creationTime.time,
pageName = pageSnapshot.pageName,
pageIconPath = pageSnapshot.pageIconPath,
knownDAppMetadata = knownDAppMetadata?.let { BrowserTabLocal.KnownDAppMetadata(it.iconLink) },
pagePicturePath = pageSnapshot.pagePicturePath
)
}
@@ -0,0 +1,75 @@
package io.novafoundation.nova.feature_dapp_impl.di
import dagger.BindsInstance
import dagger.Component
import io.novafoundation.nova.common.di.CommonApi
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.di.DbApi
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_banners_api.di.BannersFeatureApi
import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi
import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_impl.presentation.addToFavourites.di.AddToFavouritesComponent
import io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.di.AuthorizedDAppsComponent
import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.di.DAppBrowserComponent
import io.novafoundation.nova.feature_dapp_impl.presentation.favorites.di.DAppFavoritesComponent
import io.novafoundation.nova.feature_dapp_impl.presentation.main.di.MainDAppComponent
import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator
import io.novafoundation.nova.feature_dapp_impl.presentation.search.di.DAppSearchComponent
import io.novafoundation.nova.feature_dapp_impl.presentation.tab.di.BrowserTabsComponent
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
@Component(
dependencies = [
DAppFeatureDependencies::class
],
modules = [
DappFeatureModule::class
]
)
@FeatureScope
interface DAppFeatureComponent : DAppFeatureApi {
fun mainComponentFactory(): MainDAppComponent.Factory
fun browserComponentFactory(): DAppBrowserComponent.Factory
fun browserTabsComponentFactory(): BrowserTabsComponent.Factory
fun dAppSearchComponentFactory(): DAppSearchComponent.Factory
fun dAppFavoritesComponentFactory(): DAppFavoritesComponent.Factory
fun addToFavouritesComponentFactory(): AddToFavouritesComponent.Factory
fun authorizedDAppsComponentFactory(): AuthorizedDAppsComponent.Factory
@Component.Factory
interface Factory {
fun create(
@BindsInstance router: DAppRouter,
@BindsInstance signCommunicator: ExternalSignCommunicator,
@BindsInstance searchCommunicator: DAppSearchCommunicator,
deps: DAppFeatureDependencies
): DAppFeatureComponent
}
@Component(
dependencies = [
CommonApi::class,
DbApi::class,
AccountFeatureApi::class,
WalletFeatureApi::class,
RuntimeApi::class,
CurrencyFeatureApi::class,
BannersFeatureApi::class,
WalletConnectFeatureApi::class
]
)
interface DAppFeatureDependenciesComponent : DAppFeatureDependencies
}
@@ -0,0 +1,126 @@
package io.novafoundation.nova.feature_dapp_impl.di
import android.content.Context
import coil.ImageLoader
import com.google.gson.Gson
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.data.network.AppLinksProvider
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.data.providers.deviceid.DeviceIdProvider
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.interfaces.FileProvider
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.resources.ContextManager
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.IntegrityService
import io.novafoundation.nova.common.utils.ToastMessageManager
import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooserFactory
import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAskerFactory
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.BrowserHostSettingsDao
import io.novafoundation.nova.core_db.dao.BrowserTabsDao
import io.novafoundation.nova.core_db.dao.DappAuthorizationDao
import io.novafoundation.nova.core_db.dao.FavouriteDAppsDao
import io.novafoundation.nova.core_db.dao.PhishingSitesDao
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
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_banners_api.presentation.PromotionBannersMixinFactory
import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory
import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin
import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService
import io.novafoundation.nova.runtime.di.ExtrinsicSerialization
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.RuntimeVersionsRepository
interface DAppFeatureDependencies {
val amountFormatter: AmountFormatter
val context: Context
val browserTabsDao: BrowserTabsDao
val phishingSitesDao: PhishingSitesDao
val favouriteDAppsDao: FavouriteDAppsDao
val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory
val walletUiUseCase: WalletUiUseCase
val walletRepository: WalletRepository
val fileProvider: FileProvider
val contextManager: ContextManager
val rootScope: RootScope
val permissionsAskerFactory: PermissionsAskerFactory
val bannerSourceFactory: BannersSourceFactory
val bannersMixinFactory: PromotionBannersMixinFactory
val webViewPermissionAskerFactory: WebViewPermissionAskerFactory
val webViewFileChooserFactory: WebViewFileChooserFactory
val preferences: Preferences
val walletConnectService: WalletConnectService
val automaticInteractionGate: AutomaticInteractionGate
val integrityService: IntegrityService
val deviceIdProvider: DeviceIdProvider
fun currencyRepository(): CurrencyRepository
fun accountRepository(): AccountRepository
fun resourceManager(): ResourceManager
fun appLinksProvider(): AppLinksProvider
fun selectedAccountUseCase(): SelectedAccountUseCase
fun addressIconGenerator(): AddressIconGenerator
fun gson(): Gson
fun chainRegistry(): ChainRegistry
fun imageLoader(): ImageLoader
fun feeLoaderMixinFactory(): FeeLoaderMixin.Factory
fun extrinsicService(): ExtrinsicService
fun tokenRepository(): TokenRepository
fun secretStoreV2(): SecretStoreV2
@ExtrinsicSerialization
fun extrinsicGson(): Gson
fun apiCreator(): NetworkApiCreator
fun runtimeVersionsRepository(): RuntimeVersionsRepository
fun dappAuthorizationDao(): DappAuthorizationDao
fun browserHostSettingsDao(): BrowserHostSettingsDao
fun toastMessageManager(): ToastMessageManager
}
@@ -0,0 +1,41 @@
package io.novafoundation.nova.feature_dapp_impl.di
import io.novafoundation.nova.common.di.FeatureApiHolder
import io.novafoundation.nova.common.di.FeatureContainer
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.core_db.di.DbApi
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_banners_api.di.BannersFeatureApi
import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
import javax.inject.Inject
@ApplicationScope
class DAppFeatureHolder @Inject constructor(
featureContainer: FeatureContainer,
private val router: DAppRouter,
private val signCommunicator: ExternalSignCommunicator,
private val searchCommunicator: DAppSearchCommunicator,
) : FeatureApiHolder(featureContainer) {
override fun initializeDependencies(): Any {
val dApp = DaggerDAppFeatureComponent_DAppFeatureDependenciesComponent.builder()
.commonApi(commonApi())
.dbApi(getFeature(DbApi::class.java))
.accountFeatureApi(getFeature(AccountFeatureApi::class.java))
.walletFeatureApi(getFeature(WalletFeatureApi::class.java))
.runtimeApi(getFeature(RuntimeApi::class.java))
.bannersFeatureApi(getFeature(BannersFeatureApi::class.java))
.currencyFeatureApi(getFeature(CurrencyFeatureApi::class.java))
.walletConnectFeatureApi(getFeature(WalletConnectFeatureApi::class.java))
.build()
return DaggerDAppFeatureComponent.factory()
.create(router, signCommunicator, searchCommunicator, dApp)
}
}
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_dapp_impl.di
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository
import io.novafoundation.nova.feature_dapp_impl.data.repository.PhishingSitesRepository
import io.novafoundation.nova.feature_dapp_impl.di.modules.BrowserTabsModule
import io.novafoundation.nova.feature_dapp_impl.di.modules.DappMetadataModule
import io.novafoundation.nova.feature_dapp_impl.di.modules.FavouritesDAppModule
import io.novafoundation.nova.feature_dapp_impl.di.modules.PhishingSitesModule
import io.novafoundation.nova.feature_dapp_impl.di.modules.Web3Module
import io.novafoundation.nova.feature_dapp_impl.di.modules.deeplinks.DeepLinkModule
import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor
@Module(
includes = [
Web3Module::class,
DappMetadataModule::class,
PhishingSitesModule::class,
FavouritesDAppModule::class,
BrowserTabsModule::class,
DeepLinkModule::class
]
)
class DappFeatureModule {
@Provides
@FeatureScope
fun provideCommonInteractor(
dAppMetadataRepository: DAppMetadataRepository,
favouritesDAppRepository: FavouritesDAppRepository,
phishingSitesRepository: PhishingSitesRepository
) = DappInteractor(
dAppMetadataRepository = dAppMetadataRepository,
favouritesDAppRepository = favouritesDAppRepository,
phishingSitesRepository = phishingSitesRepository
)
}
@@ -0,0 +1,113 @@
package io.novafoundation.nova.feature_dapp_impl.di.modules
import android.content.Context
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.data.providers.deviceid.DeviceIdProvider
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.interfaces.FileProvider
import io.novafoundation.nova.common.resources.ContextManager
import io.novafoundation.nova.common.utils.IntegrityService
import io.novafoundation.nova.common.utils.coroutines.RootScope
import io.novafoundation.nova.core_db.dao.BrowserTabsDao
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserTabExternalRepository
import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.BrowserTabService
import io.novafoundation.nova.feature_dapp_impl.data.repository.tabs.BrowserTabInternalRepository
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.RealPageSnapshotBuilder
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.RealBrowserTabService
import io.novafoundation.nova.feature_dapp_impl.data.repository.tabs.RealBrowserTabRepository
import io.novafoundation.nova.feature_dapp_impl.utils.integrityCheck.IntegrityCheckJSBridgeFactory
import io.novafoundation.nova.feature_dapp_impl.utils.integrityCheck.IntegrityCheckSessionFactory
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.PageSnapshotBuilder
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.RealTabMemoryRestrictionService
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.TabMemoryRestrictionService
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTabSessionFactory
import io.novafoundation.nova.feature_dapp_impl.web3.webview.CompoundWeb3Injector
@Module
class BrowserTabsModule {
@FeatureScope
@Provides
fun provideBrowserTabStorage(
browserTabsDao: BrowserTabsDao,
): BrowserTabInternalRepository {
return RealBrowserTabRepository(browserTabsDao = browserTabsDao)
}
@FeatureScope
@Provides
fun provideBrowserTabRepository(
repository: BrowserTabInternalRepository,
): BrowserTabExternalRepository {
return repository
}
@FeatureScope
@Provides
fun providePageSnapshotBuilder(fileProvider: FileProvider, rootScope: RootScope): PageSnapshotBuilder {
return RealPageSnapshotBuilder(fileProvider, rootScope)
}
@FeatureScope
@Provides
fun provideTabMemoryRestrictionService(context: Context): TabMemoryRestrictionService {
return RealTabMemoryRestrictionService(context)
}
@FeatureScope
@Provides
fun provideIntegrityCheckSessionFactory(
apiCreator: NetworkApiCreator,
preferences: Preferences,
integrityService: IntegrityService,
deviceIdProvider: DeviceIdProvider
) = IntegrityCheckSessionFactory(
apiCreator,
preferences,
integrityService,
deviceIdProvider
)
@FeatureScope
@Provides
fun provideIntegrityCheckProviderFactory(
integrityCheckSessionFactory: IntegrityCheckSessionFactory
) = IntegrityCheckJSBridgeFactory(integrityCheckSessionFactory)
@FeatureScope
@Provides
fun providePageSessionFactory(
compoundWeb3Injector: CompoundWeb3Injector,
contextManager: ContextManager,
integrityCheckJSBridgeFactory: IntegrityCheckJSBridgeFactory
): BrowserTabSessionFactory {
return BrowserTabSessionFactory(compoundWeb3Injector, contextManager, integrityCheckJSBridgeFactory)
}
@FeatureScope
@Provides
fun provideBrowserTabPoolService(
accountRepository: AccountRepository,
dAppMetadataRepository: DAppMetadataRepository,
browserTabInternalRepository: BrowserTabInternalRepository,
pageSnapshotBuilder: PageSnapshotBuilder,
tabMemoryRestrictionService: TabMemoryRestrictionService,
browserTabSessionFactory: BrowserTabSessionFactory,
rootScope: RootScope
): BrowserTabService {
return RealBrowserTabService(
browserTabInternalRepository = browserTabInternalRepository,
pageSnapshotBuilder = pageSnapshotBuilder,
tabMemoryRestrictionService = tabMemoryRestrictionService,
browserTabSessionFactory = browserTabSessionFactory,
accountRepository = accountRepository,
dAppMetadataRepository = dAppMetadataRepository,
rootScope = rootScope
)
}
}
@@ -0,0 +1,32 @@
package io.novafoundation.nova.feature_dapp_impl.di.modules
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao
import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_impl.BuildConfig
import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataApi
import io.novafoundation.nova.feature_dapp_impl.data.repository.RealDAppMetadataRepository
@Module
class DappMetadataModule {
@Provides
@FeatureScope
fun provideApi(
apiCreator: NetworkApiCreator
) = apiCreator.create(DappMetadataApi::class.java)
@Provides
@FeatureScope
fun provideDRepository(
api: DappMetadataApi,
dappHostSettingsDao: BrowserHostSettingsDao
): DAppMetadataRepository = RealDAppMetadataRepository(
dappMetadataApi = api,
remoteApiUrl = BuildConfig.DAPP_METADATAS_URL,
browserHostSettingsDao = dappHostSettingsDao
)
}
@@ -0,0 +1,18 @@
package io.novafoundation.nova.feature_dapp_impl.di.modules
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.dao.FavouriteDAppsDao
import io.novafoundation.nova.feature_dapp_impl.data.repository.DbFavouritesDAppRepository
import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository
@Module
class FavouritesDAppModule {
@Provides
@FeatureScope
fun provideFavouritesDAppRepository(
dao: FavouriteDAppsDao
): FavouritesDAppRepository = DbFavouritesDAppRepository(dao)
}
@@ -0,0 +1,47 @@
package io.novafoundation.nova.feature_dapp_impl.di.modules
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.dao.PhishingSitesDao
import io.novafoundation.nova.feature_dapp_impl.data.network.phishing.PhishingSitesApi
import io.novafoundation.nova.feature_dapp_impl.data.phisning.BlackListPhishingDetectingService
import io.novafoundation.nova.feature_dapp_impl.data.phisning.CompoundPhishingDetectingService
import io.novafoundation.nova.feature_dapp_impl.data.phisning.DomainListPhishingDetectingService
import io.novafoundation.nova.feature_dapp_impl.data.phisning.PhishingDetectingService
import io.novafoundation.nova.feature_dapp_impl.data.repository.PhishingSitesRepository
import io.novafoundation.nova.feature_dapp_impl.data.repository.PhishingSitesRepositoryImpl
private val PHISHING_DOMAINS = listOf("top")
@Module
class PhishingSitesModule {
@Provides
@FeatureScope
fun providePhishingDetectingService(
phishingSitesDao: PhishingSitesDao
): PhishingDetectingService {
return CompoundPhishingDetectingService(
listOf(
BlackListPhishingDetectingService(phishingSitesDao),
DomainListPhishingDetectingService(PHISHING_DOMAINS)
)
)
}
@Provides
@FeatureScope
fun providePhishingSitesApi(networkApiCreator: NetworkApiCreator): PhishingSitesApi {
return networkApiCreator.create(PhishingSitesApi::class.java)
}
@Provides
@FeatureScope
fun providePhishingSitesRepository(
api: PhishingSitesApi,
phishingSitesDao: PhishingSitesDao,
phishingDetectingService: PhishingDetectingService
): PhishingSitesRepository = PhishingSitesRepositoryImpl(phishingSitesDao, api, phishingDetectingService)
}
@@ -0,0 +1,67 @@
package io.novafoundation.nova.feature_dapp_impl.di.modules
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.core_db.dao.DappAuthorizationDao
import io.novafoundation.nova.feature_dapp_impl.di.modules.web3.MetamaskModule
import io.novafoundation.nova.feature_dapp_impl.di.modules.web3.PolkadotJsModule
import io.novafoundation.nova.feature_dapp_impl.web3.metamask.states.MetamaskStateFactory
import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskInjector
import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskTransportFactory
import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsInjector
import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsTransportFactory
import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.states.PolkadotJsStateFactory
import io.novafoundation.nova.feature_dapp_impl.web3.session.DbWeb3Session
import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session
import io.novafoundation.nova.feature_dapp_impl.web3.states.ExtensionStoreFactory
import io.novafoundation.nova.feature_dapp_impl.web3.webview.CompoundWeb3Injector
import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewHolder
import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewScriptInjector
@Module(includes = [PolkadotJsModule::class, MetamaskModule::class])
class Web3Module {
@Provides
@FeatureScope
fun provideWebViewHolder() = WebViewHolder()
@Provides
@FeatureScope
fun provideScriptInjector(
resourceManager: ResourceManager,
) = WebViewScriptInjector(resourceManager)
@Provides
@FeatureScope
fun provideWeb3InjectorPool(
polkadotJsInjector: PolkadotJsInjector,
metamaskInjector: MetamaskInjector,
) = CompoundWeb3Injector(
injectors = listOf(
polkadotJsInjector,
metamaskInjector
)
)
@Provides
@FeatureScope
fun provideWeb3Session(
dappAuthorizationDao: DappAuthorizationDao
): Web3Session = DbWeb3Session(dappAuthorizationDao)
@Provides
@FeatureScope
fun provideExtensionStoreFactory(
polkadotJsStateFactory: PolkadotJsStateFactory,
polkadotJsTransportFactory: PolkadotJsTransportFactory,
metamaskStateFactory: MetamaskStateFactory,
metamaskTransportFactory: MetamaskTransportFactory,
) = ExtensionStoreFactory(
polkadotJsStateFactory = polkadotJsStateFactory,
polkadotJsTransportFactory = polkadotJsTransportFactory,
metamaskStateFactory = metamaskStateFactory,
metamaskTransportFactory = metamaskTransportFactory
)
}
@@ -0,0 +1,35 @@
package io.novafoundation.nova.feature_dapp_impl.di.modules.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_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_api.di.deeplinks.DAppDeepLinks
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.deeplink.DAppDeepLinkHandler
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.BrowserTabService
@Module
class DeepLinkModule {
@Provides
@FeatureScope
fun provideDappDeepLinkHandler(
dAppMetadataRepository: DAppMetadataRepository,
router: DAppRouter,
automaticInteractionGate: AutomaticInteractionGate,
browserTabService: BrowserTabService
) = DAppDeepLinkHandler(
dAppMetadataRepository,
router,
automaticInteractionGate,
browserTabService
)
@Provides
@FeatureScope
fun provideDeepLinks(dapp: DAppDeepLinkHandler): DAppDeepLinks {
return DAppDeepLinks(listOf(dapp))
}
}
@@ -0,0 +1,103 @@
package io.novafoundation.nova.feature_dapp_impl.di.modules.web3
import com.google.gson.Gson
import dagger.Binds
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_dapp_impl.BuildConfig
import io.novafoundation.nova.feature_dapp_impl.data.repository.DefaultMetamaskChainRepository
import io.novafoundation.nova.feature_dapp_impl.data.repository.RealDefaultMetamaskChainRepository
import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor
import io.novafoundation.nova.feature_dapp_impl.domain.browser.metamask.MetamaskInteractor
import io.novafoundation.nova.feature_dapp_impl.web3.metamask.di.Metamask
import io.novafoundation.nova.feature_dapp_impl.web3.metamask.states.MetamaskStateFactory
import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskInjector
import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskResponder
import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskTransportFactory
import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session
import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewHolder
import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewScriptInjector
import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewWeb3JavaScriptInterface
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [MetamaskModule.BindsModule::class])
class MetamaskModule {
@Module
interface BindsModule {
@Binds
fun bindDefaultMetamaskChainRepository(implementation: RealDefaultMetamaskChainRepository): DefaultMetamaskChainRepository
}
@Provides
@Metamask
@FeatureScope
fun provideWeb3JavaScriptInterface() = WebViewWeb3JavaScriptInterface()
@Provides
@FeatureScope
fun provideInjector(
gson: Gson,
webViewScriptInjector: WebViewScriptInjector,
@Metamask web3JavaScriptInterface: WebViewWeb3JavaScriptInterface,
) = MetamaskInjector(
isDebug = BuildConfig.DEBUG,
gson = gson,
jsInterface = web3JavaScriptInterface,
webViewScriptInjector = webViewScriptInjector
)
@Provides
@FeatureScope
fun provideResponder(webViewHolder: WebViewHolder): MetamaskResponder {
return MetamaskResponder(webViewHolder)
}
@Provides
@FeatureScope
fun provideTransportFactory(
responder: MetamaskResponder,
@Metamask web3JavaScriptInterface: WebViewWeb3JavaScriptInterface,
gson: Gson
): MetamaskTransportFactory {
return MetamaskTransportFactory(
webViewWeb3JavaScriptInterface = web3JavaScriptInterface,
gson = gson,
responder = responder,
)
}
@Provides
@FeatureScope
fun provideInteractor(
accountRepository: AccountRepository,
chainRegistry: ChainRegistry,
defaultMetamaskChainRepository: DefaultMetamaskChainRepository,
) = MetamaskInteractor(accountRepository, chainRegistry, defaultMetamaskChainRepository)
@Provides
@FeatureScope
fun provideStateFactory(
interactor: MetamaskInteractor,
commonInteractor: DappInteractor,
resourceManager: ResourceManager,
addressIconGenerator: AddressIconGenerator,
web3Session: Web3Session,
walletUiUseCase: WalletUiUseCase,
): MetamaskStateFactory {
return MetamaskStateFactory(
interactor = interactor,
commonInteractor = commonInteractor,
resourceManager = resourceManager,
addressIconGenerator = addressIconGenerator,
web3Session = web3Session,
walletUiUseCase = walletUiUseCase
)
}
}
@@ -0,0 +1,87 @@
package io.novafoundation.nova.feature_dapp_impl.di.modules.web3
import com.google.gson.Gson
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor
import io.novafoundation.nova.feature_dapp_impl.domain.browser.polkadotJs.PolkadotJsExtensionInteractor
import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsInjector
import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsResponder
import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsTransportFactory
import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.di.PolkadotJs
import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.states.PolkadotJsStateFactory
import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session
import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewHolder
import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewScriptInjector
import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewWeb3JavaScriptInterface
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.RuntimeVersionsRepository
@Module
class PolkadotJsModule {
@Provides
@PolkadotJs
@FeatureScope
fun provideWeb3JavaScriptInterface() = WebViewWeb3JavaScriptInterface()
@Provides
@FeatureScope
fun providePolkadotJsInjector(
webViewScriptInjector: WebViewScriptInjector,
@PolkadotJs web3JavaScriptInterface: WebViewWeb3JavaScriptInterface,
) = PolkadotJsInjector(web3JavaScriptInterface, webViewScriptInjector)
@Provides
@FeatureScope
fun provideResponder(webViewHolder: WebViewHolder): PolkadotJsResponder {
return PolkadotJsResponder(webViewHolder)
}
@Provides
@FeatureScope
fun providePolkadotJsTransportFactory(
web3Responder: PolkadotJsResponder,
@PolkadotJs web3JavaScriptInterface: WebViewWeb3JavaScriptInterface,
gson: Gson
): PolkadotJsTransportFactory {
return PolkadotJsTransportFactory(
webViewWeb3JavaScriptInterface = web3JavaScriptInterface,
gson = gson,
web3Responder = web3Responder,
)
}
@Provides
@FeatureScope
fun providePolkadotJsInteractor(
chainRegistry: ChainRegistry,
runtimeVersionsRepository: RuntimeVersionsRepository,
accountRepository: AccountRepository
) = PolkadotJsExtensionInteractor(chainRegistry, accountRepository, runtimeVersionsRepository)
@Provides
@FeatureScope
fun providePolkadotJsStateFactory(
interactor: PolkadotJsExtensionInteractor,
commonInteractor: DappInteractor,
resourceManager: ResourceManager,
addressIconGenerator: AddressIconGenerator,
web3Session: Web3Session,
walletUiUseCase: WalletUiUseCase,
): PolkadotJsStateFactory {
return PolkadotJsStateFactory(
interactor = interactor,
commonInteractor = commonInteractor,
resourceManager = resourceManager,
addressIconGenerator = addressIconGenerator,
web3Session = web3Session,
walletUiUseCase = walletUiUseCase
)
}
}
@@ -0,0 +1,117 @@
package io.novafoundation.nova.feature_dapp_impl.domain
import io.novafoundation.nova.common.utils.Urls
import io.novafoundation.nova.feature_dapp_api.data.model.DApp
import io.novafoundation.nova.feature_dapp_api.data.model.DAppGroupedCatalog
import io.novafoundation.nova.feature_dapp_api.data.model.DAppUrl
import io.novafoundation.nova.feature_dapp_api.data.model.DappCategory
import io.novafoundation.nova.feature_dapp_api.data.model.DappMetadata
import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_impl.data.model.FavouriteDApp
import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository
import io.novafoundation.nova.feature_dapp_impl.data.repository.PhishingSitesRepository
import io.novafoundation.nova.feature_dapp_impl.domain.browser.DAppInfo
import io.novafoundation.nova.feature_dapp_impl.domain.common.buildUrlToDappMapping
import io.novafoundation.nova.feature_dapp_impl.domain.common.createDAppComparator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.withContext
import kotlin.random.Random
class DappInteractor(
private val dAppMetadataRepository: DAppMetadataRepository,
private val favouritesDAppRepository: FavouritesDAppRepository,
private val phishingSitesRepository: PhishingSitesRepository,
) {
private val dAppComparator by lazy {
createDAppComparator()
}
suspend fun dAppsSync() = withContext(Dispatchers.IO) {
val metadataSyncing = runSync { dAppMetadataRepository.syncDAppMetadatas() }
val phishingSitesSyncing = runSync { phishingSitesRepository.syncPhishingSites() }
joinAll(metadataSyncing, phishingSitesSyncing)
}
suspend fun removeDAppFromFavourites(dAppUrl: String) {
favouritesDAppRepository.removeFavourite(dAppUrl)
}
suspend fun getFavoriteDApps(): List<FavouriteDApp> {
return favouritesDAppRepository.getFavourites().sortDApps()
}
fun observeFavoriteDApps(): Flow<List<FavouriteDApp>> {
return favouritesDAppRepository.observeFavourites()
.map { it.sortDApps() }
}
fun observeDAppsByCategory(): Flow<DAppGroupedCatalog> {
val shufflingSeed = Random.nextInt()
return combine(
dAppMetadataRepository.observeDAppCatalog(),
favouritesDAppRepository.observeFavourites()
) { dAppCatalog, favourites ->
// We use random with seed to shuffle dapps in categories the same way during updates
val random = Random(shufflingSeed)
val categories = dAppCatalog.categories
val dapps = dAppCatalog.dApps
val urlToDAppMapping = buildUrlToDappMapping(dapps, favourites)
val popular = dAppCatalog.popular.mapNotNull { urlToDAppMapping[it] }
val catalog = categories.associateWith { getShuffledDAppsInCategory(it, dapps, urlToDAppMapping, dAppCatalog.popular.toSet(), random) }
DAppGroupedCatalog(popular, catalog)
}
}
private fun getShuffledDAppsInCategory(
category: DappCategory,
dapps: List<DappMetadata>,
urlToDAppMapping: Map<String, DApp>,
popular: Set<DAppUrl>,
shufflingSeed: Random
): List<DApp> {
val categoryDApps = dapps.filter { category in it.categories }
.map { urlToDAppMapping.getValue(it.url) }
val popularDAppsInCategory = categoryDApps.filter { it.url in popular }
val otherDAppsInCategory = categoryDApps.filterNot { it.url in popular }
return popularDAppsInCategory.shuffled(shufflingSeed) + otherDAppsInCategory.shuffled(shufflingSeed)
}
suspend fun getDAppInfo(dAppUrl: String): DAppInfo {
val baseUrl = Urls.normalizeUrl(dAppUrl)
return withContext(Dispatchers.Default) {
DAppInfo(
baseUrl = baseUrl,
metadata = dAppMetadataRepository.getDAppMetadata(baseUrl)
)
}
}
private inline fun CoroutineScope.runSync(crossinline sync: suspend () -> Unit): Job {
return async { runCatching { sync() } }
}
suspend fun updateFavoriteDapps(favoriteDapps: List<FavouriteDApp>) {
favouritesDAppRepository.updateFavoriteDapps(favoriteDapps)
}
private fun List<FavouriteDApp>.sortDApps(): List<FavouriteDApp> {
return sortedBy { it.orderingIndex }
}
}
@@ -0,0 +1,7 @@
package io.novafoundation.nova.feature_dapp_impl.domain.authorizedDApps
class AuthorizedDApp(
val baseUrl: String,
val name: String?,
val iconLink: String?
)
@@ -0,0 +1,43 @@
package io.novafoundation.nova.feature_dapp_impl.domain.authorizedDApps
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_dapp_api.data.model.DappMetadata
import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session
import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session.Authorization
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
class AuthorizedDAppsInteractor(
private val accountRepository: AccountRepository,
private val metadataRepository: DAppMetadataRepository,
private val web3Session: Web3Session
) {
suspend fun revokeAuthorization(url: String) {
val currentAccount = accountRepository.getSelectedMetaAccount()
web3Session.revokeAuthorization(url, currentAccount.id)
}
fun observeAuthorizedDApps(): Flow<List<AuthorizedDApp>> {
return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount ->
val catalog = metadataRepository.getDAppCatalog()
val dApps = catalog.dApps.associateBy(DappMetadata::baseUrl)
web3Session.observeAuthorizationsFor(metaAccount.id)
.map { authorizations -> authorizations.filter { it.state == Authorization.State.ALLOWED } }
.mapList { authorization ->
val metadata = dApps[authorization.baseUrl]
AuthorizedDApp(
baseUrl = authorization.baseUrl,
name = metadata?.name ?: authorization.dAppTitle,
iconLink = metadata?.iconLink
)
}
}
}
}
@@ -0,0 +1,27 @@
package io.novafoundation.nova.feature_dapp_impl.domain.browser
class BrowserPage(
val url: String,
val title: String?,
val synchronizedWithBrowser: Boolean
)
class BrowserPageAnalyzed(
val display: String,
val title: String?,
val url: String,
val synchronizedWithBrowser: Boolean,
val isFavourite: Boolean,
val security: Security
) {
enum class Security {
SECURE, DANGEROUS, UNKNOWN
}
}
val BrowserPageAnalyzed.isSecure
get() = security == BrowserPageAnalyzed.Security.SECURE
val BrowserPageAnalyzed.isDangerous
get() = security == BrowserPageAnalyzed.Security.DANGEROUS
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_dapp_impl.domain.browser
import io.novafoundation.nova.feature_dapp_api.data.model.DappMetadata
class DAppInfo(
val baseUrl: String,
val metadata: DappMetadata?
)
@@ -0,0 +1,56 @@
package io.novafoundation.nova.feature_dapp_impl.domain.browser
import io.novafoundation.nova.common.utils.Urls
import io.novafoundation.nova.common.utils.isSecure
import io.novafoundation.nova.feature_dapp_api.data.model.BrowserHostSettings
import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserHostSettingsRepository
import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository
import io.novafoundation.nova.feature_dapp_impl.data.repository.PhishingSitesRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.net.URL
class DappBrowserInteractor(
private val phishingSitesRepository: PhishingSitesRepository,
private val favouritesDAppRepository: FavouritesDAppRepository,
private val browserHostSettingsRepository: BrowserHostSettingsRepository
) {
suspend fun getHostSettings(url: String): BrowserHostSettings? {
return browserHostSettingsRepository.getBrowserHostSettings(url)
}
suspend fun saveHostSettings(settings: BrowserHostSettings) {
browserHostSettingsRepository.saveBrowserHostSettings(settings)
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun observeBrowserPageFor(browserPage: BrowserPage): Flow<BrowserPageAnalyzed> {
return favouritesDAppRepository.observeIsFavourite(browserPage.url).map { isFavourite ->
runCatching {
val security = when {
phishingSitesRepository.isPhishing(browserPage.url) -> BrowserPageAnalyzed.Security.DANGEROUS
URL(browserPage.url).isSecure -> BrowserPageAnalyzed.Security.SECURE
else -> BrowserPageAnalyzed.Security.UNKNOWN
}
BrowserPageAnalyzed(
display = Urls.hostOf(browserPage.url),
title = browserPage.title,
url = browserPage.url,
security = security,
isFavourite = isFavourite,
synchronizedWithBrowser = browserPage.synchronizedWithBrowser
)
}.getOrElse {
BrowserPageAnalyzed(
display = browserPage.url,
title = browserPage.title,
url = browserPage.url,
isFavourite = isFavourite,
security = BrowserPageAnalyzed.Security.UNKNOWN,
synchronizedWithBrowser = browserPage.synchronizedWithBrowser
)
}
}
}
}
@@ -0,0 +1,45 @@
package io.novafoundation.nova.feature_dapp_impl.domain.browser.addToFavourites
import io.novafoundation.nova.common.utils.Urls
import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_impl.data.model.FavouriteDApp
import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AddToFavouritesInteractor(
private val favouritesDAppRepository: FavouritesDAppRepository,
private val dAppMetadataRepository: DAppMetadataRepository,
) {
suspend fun addToFavourites(url: String, label: String, icon: String?) = withContext(Dispatchers.Default) {
val nextOrderingIndex = favouritesDAppRepository.getNextOrderingIndex()
val favorite = FavouriteDApp(
url = url,
label = label,
icon = icon,
orderingIndex = nextOrderingIndex
)
favouritesDAppRepository.addFavourite(favorite)
}
suspend fun addToFavourites(favouriteDApp: FavouriteDApp) = withContext(Dispatchers.Default) {
favouritesDAppRepository.addFavourite(favouriteDApp)
}
suspend fun resolveFavouriteDAppDisplay(url: String, suppliedLabel: String?) = withContext(Dispatchers.Default) {
val dAppMetadataExactMatch = dAppMetadataRepository.findDAppMetadataByExactUrlMatch(url)
val dAppMetadataBaseUrlMatches = dAppMetadataRepository.findDAppMetadatasByBaseUrlMatch(baseUrl = Urls.normalizeUrl(url))
// we don't want to use base url match if there more than one candidate
val dAppMetadataBaseUrlSingleMatch = dAppMetadataBaseUrlMatches.singleOrNull()
FavouriteDApp(
url = url,
label = dAppMetadataExactMatch?.name ?: suppliedLabel ?: Urls.hostOf(url),
icon = dAppMetadataExactMatch?.iconLink ?: dAppMetadataBaseUrlSingleMatch?.iconLink,
orderingIndex = 0
)
}
}
@@ -0,0 +1,61 @@
package io.novafoundation.nova.feature_dapp_impl.domain.browser.metamask
import android.util.Log
import io.novafoundation.nova.core.model.CryptoType
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.addressIn
import io.novafoundation.nova.feature_account_api.domain.model.mainEthereumAddress
import io.novafoundation.nova.feature_dapp_impl.data.repository.DefaultMetamaskChainRepository
import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.EthereumAddress
import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskChain
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.findEvmChainFromHexId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
class MetamaskInteractor(
private val accountRepository: AccountRepository,
private val chainRegistry: ChainRegistry,
private val defaultMetamaskChainRepository: DefaultMetamaskChainRepository,
) {
fun getDefaultMetamaskChain(): MetamaskChain {
val defaultChain = defaultMetamaskChainRepository.getDefaultMetamaskChain() ?: MetamaskChain.ETHEREUM
return defaultChain.also {
Log.d("MetamaskInteractor", "Returned default chain: ${defaultChain.chainName}")
}
}
fun setDefaultMetamaskChain(chain: MetamaskChain) {
Log.d("MetamaskInteractor", "Saved default chain: ${chain.chainName}")
defaultMetamaskChainRepository.saveDefaultMetamaskChain(chain)
}
suspend fun getAddresses(ethereumChainId: String): List<EthereumAddress> = withContext(Dispatchers.Default) {
val selectedAccount = accountRepository.getSelectedMetaAccount()
val maybeChain = chainRegistry.findEvmChainFromHexId(ethereumChainId)
val chainsById = chainRegistry.chainsById.first()
val selectedAddress = maybeChain?.let { selectedAccount.addressIn(it) }
val mainAddress = selectedAccount.mainEthereumAddress()
val chainAccountAddresses = selectedAccount.chainAccounts
.mapNotNull { (chainId, chainAccount) ->
val chain = chainsById[chainId]
chain?.addressOf(chainAccount.accountId)?.takeIf {
chain.isEthereumBased && chainAccount.cryptoType == CryptoType.ECDSA
}
}
buildList {
selectedAddress?.let { add(it) }
mainAddress?.let { add(it) }
addAll(chainAccountAddresses)
}.distinct()
}
}
@@ -0,0 +1,81 @@
package io.novafoundation.nova.feature_dapp_impl.domain.browser.polkadotJs
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.defaultSubstrateAddress
import io.novafoundation.nova.feature_account_api.domain.model.substrateFrom
import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.model.InjectedAccount
import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.model.InjectedMetadataKnown
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.ext.genesisHash
import io.novafoundation.nova.runtime.ext.toEthereumAddress
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.RuntimeVersionsRepository
import io.novasama.substrate_sdk_android.encrypt.MultiChainEncryption
import io.novasama.substrate_sdk_android.extensions.requireHexPrefix
class PolkadotJsExtensionInteractor(
private val chainRegistry: ChainRegistry,
private val accountRepository: AccountRepository,
private val runtimeVersionsRepository: RuntimeVersionsRepository,
) {
suspend fun getInjectedAccounts(): List<InjectedAccount> {
val metaAccount = accountRepository.getSelectedMetaAccount()
val defaultSubstrateAccount = metaAccount.defaultSubstrateAddress?.let { address ->
InjectedAccount(
address = address,
genesisHash = null,
name = metaAccount.name,
encryption = metaAccount.substrateCryptoType?.let { MultiChainEncryption.substrateFrom(it) }
)
}
val defaultEthereumAccount = metaAccount.ethereumAddress?.let { adddressBytes ->
InjectedAccount(
address = adddressBytes.toEthereumAddress(),
genesisHash = null,
name = metaAccount.name,
encryption = MultiChainEncryption.Ethereum
)
}
val customAccounts = metaAccount.chainAccounts.mapNotNull { (chainId, chainAccount) ->
val chain = chainRegistry.getChain(chainId)
// Ignore non-substrate chains since they don't have chainId=genesisHash
val genesisHash = chain.genesisHash?.requireHexPrefix() ?: return@mapNotNull null
InjectedAccount(
address = chain.addressOf(chainAccount.accountId),
genesisHash = genesisHash,
name = "${metaAccount.name} (${chain.name})",
encryption = chainAccount.multiChainEncryption(chain)
)
}
return buildList {
defaultSubstrateAccount?.let(::add)
defaultEthereumAccount?.let(::add)
addAll(customAccounts)
}
}
suspend fun getKnownInjectedMetadatas(): List<InjectedMetadataKnown> {
return runtimeVersionsRepository.getAllRuntimeVersions().map {
InjectedMetadataKnown(
genesisHash = it.chainId.requireHexPrefix(),
specVersion = it.specVersion
)
}
}
private fun MetaAccount.ChainAccount.multiChainEncryption(chain: Chain): MultiChainEncryption? {
return if (chain.isEthereumBased) {
MultiChainEncryption.Ethereum
} else {
cryptoType?.let { MultiChainEncryption.substrateFrom(it) }
}
}
}
@@ -0,0 +1,61 @@
package io.novafoundation.nova.feature_dapp_impl.domain.common
import io.novafoundation.nova.feature_dapp_api.data.model.DApp
import io.novafoundation.nova.feature_dapp_api.data.model.DappMetadata
import io.novafoundation.nova.feature_dapp_impl.data.model.FavouriteDApp
import io.novafoundation.nova.feature_dapp_impl.presentation.common.mapDappCategoriesToDescription
fun createDAppComparator() = compareByDescending<DApp> { it.isFavourite }
.thenBy { it.favoriteIndex }
.thenBy { it.name }
// Build mapping in O(Metadatas + Favourites) in case of HashMap. It allows constant time access later
internal fun buildUrlToDappMapping(
dAppMetadatas: Collection<DappMetadata>,
favourites: Collection<FavouriteDApp>
): Map<String, DApp> {
val favouritesByUrl = favourites.associateBy { it.url }
return buildMap {
val fromFavourites = favouritesByUrl.mapValues { favouriteToDApp(it.value) }
putAll(fromFavourites)
// overlapping metadata urls will override favourites in the map and thus use metadata for display
val fromMetadatas = dAppMetadatas.associateBy(
keySelector = { it.url },
valueTransform = { dAppMetadataToDApp(it, favoriteModel = favouritesByUrl[it.url]) }
)
putAll(fromMetadatas)
}
}
fun dappToFavorite(dapp: DApp, orderingIndex: Int): FavouriteDApp {
return FavouriteDApp(
label = dapp.name,
icon = dapp.iconLink,
url = dapp.url,
orderingIndex = orderingIndex
)
}
fun favouriteToDApp(favouriteDApp: FavouriteDApp): DApp {
return DApp(
name = favouriteDApp.label,
description = favouriteDApp.url,
iconLink = favouriteDApp.icon,
url = favouriteDApp.url,
isFavourite = true,
favoriteIndex = favouriteDApp.orderingIndex
)
}
private fun dAppMetadataToDApp(metadata: DappMetadata, favoriteModel: FavouriteDApp?): DApp {
return DApp(
name = metadata.name,
description = mapDappCategoriesToDescription(metadata.categories),
iconLink = metadata.iconLink,
url = metadata.url,
isFavourite = favoriteModel != null,
favoriteIndex = favoriteModel?.orderingIndex
)
}
@@ -0,0 +1,5 @@
package io.novafoundation.nova.feature_dapp_impl.domain.search
enum class DappSearchGroup {
DAPPS, SEARCH
}
@@ -0,0 +1,18 @@
package io.novafoundation.nova.feature_dapp_impl.domain.search
import io.novafoundation.nova.feature_dapp_api.data.model.DApp
sealed interface DappSearchResult {
val isTrustedByNova: Boolean
class Url(val url: String, override val isTrustedByNova: Boolean) : DappSearchResult
class Search(val query: String, val searchUrl: String) : DappSearchResult {
override val isTrustedByNova: Boolean = false
}
class Dapp(val dapp: DApp) : DappSearchResult {
override val isTrustedByNova: Boolean = true
}
}
@@ -0,0 +1,70 @@
package io.novafoundation.nova.feature_dapp_impl.domain.search
import io.novafoundation.nova.common.list.GroupedList
import io.novafoundation.nova.common.utils.Urls
import io.novafoundation.nova.feature_dapp_api.data.model.DApp
import io.novafoundation.nova.feature_dapp_api.data.model.DappCategory
import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository
import io.novafoundation.nova.feature_dapp_impl.domain.common.buildUrlToDappMapping
import io.novafoundation.nova.feature_dapp_impl.domain.common.createDAppComparator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
class SearchDappInteractor(
private val dAppMetadataRepository: DAppMetadataRepository,
private val favouritesDAppRepository: FavouritesDAppRepository,
) {
fun categories(): Flow<List<DappCategory>> {
return dAppMetadataRepository.observeDAppCatalog()
.map { it.categories }
}
suspend fun searchDapps(query: String, categoryId: String?): GroupedList<DappSearchGroup, DappSearchResult> = withContext(Dispatchers.Default) {
val dapps = getDapps(categoryId)
val dappsGroupContent = dapps
.filter { query.isEmpty() || query.lowercase() in it.name.lowercase() }
.sortedWith(createDAppComparator())
.map(DappSearchResult::Dapp)
val searchGroupContent = when {
query.isEmpty() -> null
Urls.isValidWebUrl(query) -> {
val searchUrl = Urls.ensureHttpsProtocol(query)
val searchUrlDomain = Urls.domainOf(searchUrl)
val trusting = dapps.any { Urls.domainOf(it.url) == searchUrlDomain }
DappSearchResult.Url(searchUrl, trusting)
}
else -> DappSearchResult.Search(query, searchUrlFor(query))
}
buildMap {
searchGroupContent?.let {
put(DappSearchGroup.SEARCH, listOf(searchGroupContent))
}
if (dappsGroupContent.isNotEmpty()) {
put(DappSearchGroup.DAPPS, dappsGroupContent)
}
}
}
private fun searchUrlFor(query: String): String = "https://duckduckgo.com/?q=$query"
private suspend fun getDapps(categoryId: String?): Collection<DApp> {
val dApps = dAppMetadataRepository.getDAppCatalog()
.dApps
.filter { dapp -> categoryId == null || dapp.categories.any { it.id == categoryId } }
.associateBy { it.url }
val favouriteDApps = favouritesDAppRepository.getFavourites()
.filter { categoryId == null || it.url in dApps.keys }
val dAppByUrlMapping = buildUrlToDappMapping(dApps.values, favouriteDApps)
return dAppByUrlMapping.values
}
}
@@ -0,0 +1,29 @@
package io.novafoundation.nova.feature_dapp_impl.presentation
import androidx.navigation.fragment.FragmentNavigator
import io.novafoundation.nova.common.navigation.ReturnableRouter
import io.novafoundation.nova.feature_dapp_api.presentation.addToFavorites.AddToFavouritesPayload
import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload
interface DAppRouter : ReturnableRouter {
fun openChangeAccount()
fun openDAppBrowser(payload: DAppBrowserPayload, extras: FragmentNavigator.Extras? = null)
fun openDappSearch()
fun openDappSearchWithCategory(categoryId: String?)
fun finishDappSearch()
fun openAddToFavourites(payload: AddToFavouritesPayload)
fun openAuthorizedDApps()
fun openTabs()
fun closeTabsScreen()
fun openDAppFavorites()
}
@@ -0,0 +1,74 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.addToFavourites
import androidx.core.os.bundleOf
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.bindTo
import io.novafoundation.nova.common.utils.hideKeyboard
import io.novafoundation.nova.common.utils.keyboard.showSoftKeyboard
import io.novafoundation.nova.common.utils.moveCursorToTheEnd
import io.novafoundation.nova.common.utils.postToSelf
import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi
import io.novafoundation.nova.feature_dapp_api.presentation.addToFavorites.AddToFavouritesPayload
import io.novafoundation.nova.feature_dapp_impl.databinding.FragmentAddToFavouritesBinding
import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureComponent
import io.novafoundation.nova.feature_external_sign_api.presentation.dapp.showDAppIcon
import javax.inject.Inject
private const val PAYLOAD_KEY = "DAppSignExtrinsicFragment.Payload"
class AddToFavouritesFragment : BaseFragment<AddToFavouritesViewModel, FragmentAddToFavouritesBinding>() {
companion object {
fun getBundle(payload: AddToFavouritesPayload) = bundleOf(PAYLOAD_KEY to payload)
}
override fun createBinding() = FragmentAddToFavouritesBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
override fun initViews() {
binder.addToFavouritesToolbar.setHomeButtonListener {
viewModel.backClicked()
}
binder.addToFavouritesToolbar.setRightActionClickListener { viewModel.saveClicked() }
}
override fun onDestroyView() {
super.onDestroyView()
hideKeyboard()
}
override fun inject() {
FeatureUtils.getFeature<DAppFeatureComponent>(this, DAppFeatureApi::class.java)
.addToFavouritesComponentFactory()
.create(this, argument(PAYLOAD_KEY))
.inject(this)
}
@Suppress("UNCHECKED_CAST")
override fun subscribe(viewModel: AddToFavouritesViewModel) {
binder.addToFavouritesTitleInput.bindTo(viewModel.labelFlow, lifecycleScope)
binder.addToFavouritesAddressInput.bindTo(viewModel.urlFlow, lifecycleScope)
viewModel.iconLink.observe {
binder.addToFavouritesIcon.showDAppIcon(it, imageLoader)
}
viewModel.focusOnAddressFieldEvent.observeEvent {
binder.addToFavouritesTitleInput.postToSelf {
showSoftKeyboard()
moveCursorToTheEnd()
}
}
}
}
@@ -0,0 +1,52 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.addToFavourites
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.sendEvent
import io.novafoundation.nova.common.utils.singleReplaySharedFlow
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_api.presentation.addToFavorites.AddToFavouritesPayload
import io.novafoundation.nova.feature_dapp_impl.domain.browser.addToFavourites.AddToFavouritesInteractor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class AddToFavouritesViewModel(
private val interactor: AddToFavouritesInteractor,
private val payload: AddToFavouritesPayload,
private val router: DAppRouter,
) : BaseViewModel() {
val urlFlow = MutableStateFlow(payload.url)
val labelFlow = singleReplaySharedFlow<String>()
val iconLink = singleReplaySharedFlow<String?>()
private val _focusOnUrlFieldEvent = MutableLiveData<Event<Unit>>()
val focusOnAddressFieldEvent: LiveData<Event<Unit>> = _focusOnUrlFieldEvent
init {
setInitialValues()
}
fun saveClicked() = launch {
interactor.addToFavourites(urlFlow.value, labelFlow.first(), iconLink.first())
router.back()
}
private fun setInitialValues() = launch {
val resolvedDAppDisplay = interactor.resolveFavouriteDAppDisplay(url = payload.url, suppliedLabel = payload.label)
labelFlow.emit(resolvedDAppDisplay.label)
iconLink.emit(resolvedDAppDisplay.icon)
_focusOnUrlFieldEvent.sendEvent()
}
fun backClicked() {
router.back()
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.addToFavourites.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_dapp_impl.presentation.addToFavourites.AddToFavouritesFragment
import io.novafoundation.nova.feature_dapp_api.presentation.addToFavorites.AddToFavouritesPayload
@Subcomponent(
modules = [
AddToFavouritesModule::class
]
)
@ScreenScope
interface AddToFavouritesComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: AddToFavouritesPayload,
): AddToFavouritesComponent
}
fun inject(fragment: AddToFavouritesFragment)
}
@@ -0,0 +1,49 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.addToFavourites.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.scope.ScreenScope
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository
import io.novafoundation.nova.feature_dapp_impl.domain.browser.addToFavourites.AddToFavouritesInteractor
import io.novafoundation.nova.feature_dapp_api.presentation.addToFavorites.AddToFavouritesPayload
import io.novafoundation.nova.feature_dapp_impl.presentation.addToFavourites.AddToFavouritesViewModel
@Module(includes = [ViewModelModule::class])
class AddToFavouritesModule {
@Provides
@ScreenScope
fun provideInteractor(
favouritesDAppRepository: FavouritesDAppRepository,
dAppMetadataRepository: DAppMetadataRepository
) = AddToFavouritesInteractor(favouritesDAppRepository, dAppMetadataRepository)
@Provides
@ScreenScope
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): AddToFavouritesViewModel {
return ViewModelProvider(fragment, factory).get(AddToFavouritesViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(AddToFavouritesViewModel::class)
fun provideViewModel(
router: DAppRouter,
interactor: AddToFavouritesInteractor,
payload: AddToFavouritesPayload
): ViewModel {
return AddToFavouritesViewModel(
router = router,
interactor = interactor,
payload = payload
)
}
}
@@ -0,0 +1,62 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps
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_impl.R
import io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.model.AuthorizedDAppModel
import io.novafoundation.nova.feature_dapp_api.presentation.view.DAppView
class AuthorizedDAppAdapter(
private val handler: Handler
) : BaseListAdapter<AuthorizedDAppModel, AuthorizedDAppViewHolder>(DiffCallback) {
interface Handler {
fun onRevokeClicked(item: AuthorizedDAppModel)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AuthorizedDAppViewHolder {
return AuthorizedDAppViewHolder(DAppView.createUsingMathParentWidth(parent.context), handler)
}
override fun onBindViewHolder(holder: AuthorizedDAppViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
private object DiffCallback : DiffUtil.ItemCallback<AuthorizedDAppModel>() {
override fun areItemsTheSame(oldItem: AuthorizedDAppModel, newItem: AuthorizedDAppModel): Boolean {
return oldItem.url == newItem.url
}
override fun areContentsTheSame(oldItem: AuthorizedDAppModel, newItem: AuthorizedDAppModel): Boolean {
return oldItem == newItem
}
}
class AuthorizedDAppViewHolder(
private val dAppView: DAppView,
private val itemHandler: AuthorizedDAppAdapter.Handler,
) : BaseViewHolder(dAppView) {
init {
dAppView.setActionResource(R.drawable.ic_close)
dAppView.setActionTintRes(R.color.icon_secondary)
}
fun bind(item: AuthorizedDAppModel) = with(dAppView) {
this.setTitle(item.title)
this.showTitle(item.title != null)
this.setSubtitle(item.url)
this.setIconUrl(item.iconLink)
setOnActionClickListener { itemHandler.onRevokeClicked(item) }
}
override fun unbind() {
dAppView.clearIcon()
}
}
@@ -0,0 +1,70 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps
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.common.view.dialog.warningDialog
import io.novafoundation.nova.feature_account_api.view.showWallet
import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi
import io.novafoundation.nova.feature_dapp_impl.R
import io.novafoundation.nova.feature_dapp_impl.databinding.FragmentAuthorizedDappsBinding
import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureComponent
import io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.model.AuthorizedDAppModel
class AuthorizedDAppsFragment : BaseFragment<AuthorizedDAppsViewModel, FragmentAuthorizedDappsBinding>(), AuthorizedDAppAdapter.Handler {
override fun createBinding() = FragmentAuthorizedDappsBinding.inflate(layoutInflater)
private val adapter by lazy(LazyThreadSafetyMode.NONE) {
AuthorizedDAppAdapter(this)
}
private val placeholderViews by lazy(LazyThreadSafetyMode.NONE) {
listOf(binder.authorizedPlaceholderSpacerTop, binder.authorizedPlaceholder, binder.authorizedPlaceholderSpacerBottom)
}
override fun initViews() {
binder.authorizedDAppsToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.authorizedDAppsList.setHasFixedSize(true)
binder.authorizedDAppsList.adapter = adapter
}
override fun inject() {
FeatureUtils.getFeature<DAppFeatureComponent>(this, DAppFeatureApi::class.java)
.authorizedDAppsComponentFactory()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: AuthorizedDAppsViewModel) {
viewModel.authorizedDApps.observe {
val showPlaceholder = it.isEmpty()
binder.authorizedDAppsList.setVisible(!showPlaceholder)
placeholderViews.forEach { view -> view.setVisible(showPlaceholder) }
adapter.submitList(it)
}
viewModel.walletUi.observe {
binder.authorizedDAppsWallet.showWallet(it)
}
viewModel.revokeAuthorizationConfirmation.awaitableActionLiveData.observeEvent {
warningDialog(
context = requireContext(),
onPositiveClick = { it.onSuccess(Unit) },
onNegativeClick = it.onCancel,
positiveTextRes = R.string.common_remove
) {
setTitle(R.string.dapp_authorized_remove_title)
setMessage(getString(R.string.dapp_authorized_remove_description, it.payload))
}
}
}
override fun onRevokeClicked(item: AuthorizedDAppModel) {
viewModel.revokeClicked(item)
}
}
@@ -0,0 +1,52 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_impl.domain.authorizedDApps.AuthorizedDApp
import io.novafoundation.nova.feature_dapp_impl.domain.authorizedDApps.AuthorizedDAppsInteractor
import io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.model.AuthorizedDAppModel
import kotlinx.coroutines.launch
typealias RevokeAuthorizationPayload = String // dApp name
class AuthorizedDAppsViewModel(
private val interactor: AuthorizedDAppsInteractor,
private val router: DAppRouter,
private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
walletUiUseCase: WalletUiUseCase,
) : BaseViewModel() {
val revokeAuthorizationConfirmation = actionAwaitableMixinFactory.confirmingAction<RevokeAuthorizationPayload>()
val walletUi = walletUiUseCase.selectedWalletUiFlow(showAddressIcon = true)
.shareInBackground()
val authorizedDApps = interactor.observeAuthorizedDApps()
.mapList(::mapAuthorizedDAppToModel)
.shareInBackground()
fun backClicked() {
router.back()
}
fun revokeClicked(item: AuthorizedDAppModel) = launch {
val dAppTitle = item.title ?: item.url
revokeAuthorizationConfirmation.awaitAction(dAppTitle)
interactor.revokeAuthorization(item.url)
}
private fun mapAuthorizedDAppToModel(
authorizedDApp: AuthorizedDApp
): AuthorizedDAppModel {
return AuthorizedDAppModel(
title = authorizedDApp.name,
url = authorizedDApp.baseUrl,
iconLink = authorizedDApp.iconLink
)
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.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_dapp_impl.presentation.authorizedDApps.AuthorizedDAppsFragment
@Subcomponent(
modules = [
AuthorizedDAppsModule::class
]
)
@ScreenScope
interface AuthorizedDAppsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
): AuthorizedDAppsComponent
}
fun inject(fragment: AuthorizedDAppsFragment)
}
@@ -0,0 +1,58 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.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.scope.ScreenScope
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_impl.domain.authorizedDApps.AuthorizedDAppsInteractor
import io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.AuthorizedDAppsViewModel
import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session
@Module(includes = [ViewModelModule::class])
class AuthorizedDAppsModule {
@Provides
@ScreenScope
fun provideInteractor(
accountRepository: AccountRepository,
metadataRepository: DAppMetadataRepository,
web3Session: Web3Session
) = AuthorizedDAppsInteractor(
accountRepository = accountRepository,
metadataRepository = metadataRepository,
web3Session = web3Session
)
@Provides
@ScreenScope
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): AuthorizedDAppsViewModel {
return ViewModelProvider(fragment, factory).get(AuthorizedDAppsViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(AuthorizedDAppsViewModel::class)
fun provideViewModel(
router: DAppRouter,
interactor: AuthorizedDAppsInteractor,
walletUiUseCase: WalletUiUseCase,
actionAwaitableMixinFactory: ActionAwaitableMixin.Factory
): ViewModel {
return AuthorizedDAppsViewModel(
router = router,
interactor = interactor,
walletUiUseCase = walletUiUseCase,
actionAwaitableMixinFactory = actionAwaitableMixinFactory
)
}
}
@@ -0,0 +1,7 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.model
data class AuthorizedDAppModel(
val title: String?,
val url: String,
val iconLink: String?
)
@@ -0,0 +1,12 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main
sealed class BrowserCommand {
object Reload : BrowserCommand()
object GoBack : BrowserCommand()
class OpenUrl(val url: String) : BrowserCommand()
class ChangeDesktopMode(val enabled: Boolean) : BrowserCommand()
}
@@ -0,0 +1,338 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main
import android.content.pm.ActivityInfo
import android.graphics.Bitmap
import android.os.Bundle
import android.transition.TransitionInflater
import android.view.View
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.core.app.SharedElementCallback
import androidx.core.os.bundleOf
import androidx.core.transition.addListener
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets
import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets
import io.novafoundation.nova.common.utils.makeGone
import io.novafoundation.nova.common.utils.makeVisible
import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi
import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload
import io.novafoundation.nova.feature_dapp_impl.R
import io.novafoundation.nova.feature_dapp_impl.databinding.FragmentDappBrowserBinding
import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureComponent
import io.novafoundation.nova.feature_dapp_impl.domain.browser.isSecure
import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.DappPendingConfirmation.Action
import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.sheets.AcknowledgePhishingBottomSheet
import io.novafoundation.nova.feature_dapp_impl.presentation.browser.options.OptionsBottomSheetDialog
import io.novafoundation.nova.feature_dapp_impl.presentation.common.favourites.setupRemoveFavouritesConfirmation
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTabSession
import io.novafoundation.nova.feature_dapp_impl.web3.webview.Web3ChromeClient
import io.novafoundation.nova.feature_dapp_impl.web3.webview.CompoundWeb3Injector
import io.novafoundation.nova.feature_dapp_impl.web3.webview.Web3WebViewClient
import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooser
import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewHolder
import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAsker
import io.novafoundation.nova.common.utils.webView.WebViewRequestInterceptor
import io.novafoundation.nova.common.view.dialog.infoDialog
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.SessionCallback
import io.novafoundation.nova.feature_external_sign_api.presentation.externalSign.AuthorizeDappBottomSheet
import javax.inject.Inject
private const val OVERFLOW_TABS_COUNT = 100
const val DAPP_SHARED_ELEMENT_ID_IMAGE_TAB = "DAPP_SHARED_ELEMENT_ID_IMAGE_TAB"
class DAppBrowserFragment : BaseFragment<DAppBrowserViewModel, FragmentDappBrowserBinding>(), OptionsBottomSheetDialog.Callback, SessionCallback {
companion object {
private const val PAYLOAD = "DAppBrowserFragment.Payload"
fun getBundle(payload: DAppBrowserPayload) = bundleOf(PAYLOAD to payload)
}
override fun createBinding() = FragmentDappBrowserBinding.inflate(layoutInflater)
@Inject
lateinit var compoundWeb3Injector: CompoundWeb3Injector
@Inject
lateinit var webViewHolder: WebViewHolder
@Inject
lateinit var fileChooser: WebViewFileChooser
@Inject
lateinit var permissionAsker: WebViewPermissionAsker
@Inject
lateinit var webViewRequestInterceptor: WebViewRequestInterceptor
@Inject
lateinit var imageLoader: ImageLoader
private var webViewClient: Web3WebViewClient? = null
var backCallback: OnBackPressedCallback? = null
private val dappBrowserWebView: WebView?
get() {
return binder.dappBrowserWebViewContainer.getChildAt(0) as? WebView
}
override fun onCreate(savedInstanceState: Bundle?) {
WebView.enableSlowWholeDocumentDraw()
super.onCreate(savedInstanceState)
sharedElementEnterTransition = TransitionInflater.from(requireContext())
.inflateTransition(android.R.transition.move).apply {
addListener(
onStart = { binder.dappBrowserWebViewContainer.makeGone() }, // Hide WebView during transition animation
onEnd = {
binder.dappBrowserWebViewContainer.makeVisible()
binder.dappBrowserTransitionImage.animate()
.setDuration(300)
.alpha(0f)
.withEndAction { binder.dappBrowserTransitionImage.makeGone() }
.start()
}
)
}
}
override fun applyInsets(rootView: View) {
binder.dappBrowserAddressBarGroup.applyStatusBarInsets()
binder.dappBrowserBottomNavigation.applyNavigationBarInsets()
}
override fun initViews() {
binder.dappBrowserHide.setOnClickListener { viewModel.closeClicked() }
binder.dappBrowserBack.setOnClickListener { backClicked() }
binder.dappBrowserAddressBar.setOnClickListener {
viewModel.openSearch()
}
binder.dappBrowserForward.setOnClickListener { forwardClicked() }
binder.dappBrowserTabs.setOnClickListener { viewModel.openTabs() }
binder.dappBrowserRefresh.setOnClickListener { refreshClicked() }
binder.dappBrowserFavorite.setOnClickListener { viewModel.onFavoriteClick() }
binder.dappBrowserMore.setOnClickListener { moreClicked() }
requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER
binder.dappBrowserTransitionImage.transitionName = DAPP_SHARED_ELEMENT_ID_IMAGE_TAB
setEnterSharedElementCallback(object : SharedElementCallback() {
override fun onSharedElementStart(
sharedElementNames: MutableList<String>?,
sharedElements: MutableList<View>?,
sharedElementSnapshots: MutableList<View>?
) {
val sharedView = sharedElements?.firstOrNull { it.transitionName == DAPP_SHARED_ELEMENT_ID_IMAGE_TAB }
val sharedImageView = sharedView as? ImageView
binder.dappBrowserTransitionImage.setImageDrawable(sharedImageView?.drawable) // Set image from shared element
}
})
}
override fun onDestroyView() {
binder.dappBrowserWebViewContainer.removeAllViews()
viewModel.detachCurrentSession()
super.onDestroyView()
requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
override fun onPause() {
super.onPause()
viewModel.makePageSnapshot()
detachBackCallback()
}
override fun onResume() {
super.onResume()
attachBackCallback()
}
override fun onHiddenChanged(hidden: Boolean) {
if (hidden) {
detachBackCallback()
} else {
attachBackCallback()
}
}
override fun inject() {
FeatureUtils.getFeature<DAppFeatureComponent>(this, DAppFeatureApi::class.java)
.browserComponentFactory()
.create(this, argument(PAYLOAD))
.inject(this)
}
@Suppress("UNCHECKED_CAST")
override fun subscribe(viewModel: DAppBrowserViewModel) {
setupRemoveFavouritesConfirmation(viewModel.removeFromFavouritesConfirmation)
viewModel.currentTabFlow.observe { currentTab ->
attachSession(currentTab.browserTabSession)
}
viewModel.desktopModeChangedModel.observe {
webViewClient?.desktopMode = it.desktopModeEnabled
}
viewModel.showConfirmationSheet.observeEvent {
when (it.action) {
is Action.Authorize -> {
showConfirmAuthorizeSheet(it as DappPendingConfirmation<Action.Authorize>)
}
Action.AcknowledgePhishingAlert -> {
AcknowledgePhishingBottomSheet(requireContext(), it)
.show()
}
}
}
viewModel.browserCommandEvent.observeEvent {
when (it) {
BrowserCommand.Reload -> dappBrowserWebView?.reload()
BrowserCommand.GoBack -> backClicked()
is BrowserCommand.OpenUrl -> dappBrowserWebView?.loadUrl(it.url)
is BrowserCommand.ChangeDesktopMode -> {
webViewClient?.desktopMode = it.enabled
dappBrowserWebView?.reload()
}
}
}
viewModel.openBrowserOptionsEvent.observeEvent {
val optionsBottomSheet = OptionsBottomSheetDialog(requireContext(), this, it)
optionsBottomSheet.show()
}
viewModel.currentPageAnalyzed.observe {
binder.dappBrowserAddressBar.setAddress(it.display)
binder.dappBrowserAddressBar.showSecure(it.isSecure)
binder.dappBrowserFavorite.setImageResource(favoriteIcon(it.isFavourite))
updateButtonsState()
}
viewModel.tabsCountFlow.observe {
if (it >= OVERFLOW_TABS_COUNT) {
binder.dappBrowserTabsIcon.makeVisible()
binder.dappBrowserTabsContent.text = null
} else {
binder.dappBrowserTabsIcon.makeGone()
binder.dappBrowserTabsContent.text = it.toString()
}
}
}
private fun attachSession(session: BrowserTabSession) {
clearProgress()
session.attachToHost(createChromeClient(), this)
webViewHolder.set(session.webView)
webViewClient = session.webViewClient
binder.dappBrowserWebViewContainer.removeAllViews()
binder.dappBrowserWebViewContainer.addView(session.webView)
}
private fun clearProgress() {
binder.dappBrowserProgress.makeGone()
binder.dappBrowserProgress.progress = 0
}
private fun createChromeClient() = Web3ChromeClient(permissionAsker, fileChooser, viewModel.viewModelScope, binder.dappBrowserProgress)
private fun updateButtonsState() {
binder.dappBrowserForward.isEnabled = dappBrowserWebView?.canGoForward() ?: false
binder.dappBrowserBack.isEnabled = dappBrowserWebView?.canGoBack() ?: false
}
private fun showConfirmAuthorizeSheet(pendingConfirmation: DappPendingConfirmation<Action.Authorize>) {
AuthorizeDappBottomSheet(
context = requireContext(),
onConfirm = pendingConfirmation.onConfirm,
onDeny = pendingConfirmation.onDeny,
payload = pendingConfirmation.action.content,
).show()
}
private fun backClicked() {
if (dappBrowserWebView?.canGoBack() == true) {
dappBrowserWebView?.goBack()
} else {
viewModel.closeClicked()
}
}
private fun forwardClicked() {
dappBrowserWebView?.goForward()
}
private fun refreshClicked() {
dappBrowserWebView?.reload()
}
private fun attachBackCallback() {
if (backCallback == null) {
backCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
backClicked()
}
}
requireActivity().onBackPressedDispatcher.addCallback(backCallback!!)
}
}
private fun moreClicked() {
viewModel.onMoreClicked()
}
private fun detachBackCallback() {
backCallback?.remove()
backCallback = null
}
override fun onDesktopModeClick() {
viewModel.onDesktopClick()
}
override fun onPageStarted(webView: WebView, url: String, favicon: Bitmap?) {
compoundWeb3Injector.injectForPage(webView, viewModel.extensionsStore)
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
return webViewRequestInterceptor.intercept(request)
}
override fun onPageChanged(webView: WebView, url: String?, title: String?) {
viewModel.onPageChanged(url, title)
}
override fun onPageError(error: String) {
infoDialog(requireContext()) {
setTitle(R.string.common_error_general_title)
setMessage(error)
}
}
private fun favoriteIcon(isFavorite: Boolean): Int {
return if (isFavorite) {
R.drawable.ic_favorite_heart_filled
} else {
R.drawable.ic_favorite_heart_outline
}
}
}
@@ -0,0 +1,323 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.Urls
import io.novafoundation.nova.common.utils.event
import io.novafoundation.nova.common.utils.removeHexPrefix
import io.novafoundation.nova.common.utils.singleReplaySharedFlow
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_dapp_api.data.model.BrowserHostSettings
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor
import io.novafoundation.nova.feature_dapp_impl.domain.browser.BrowserPage
import io.novafoundation.nova.feature_dapp_impl.domain.browser.BrowserPageAnalyzed
import io.novafoundation.nova.feature_dapp_impl.domain.browser.DappBrowserInteractor
import io.novafoundation.nova.feature_dapp_api.presentation.addToFavorites.AddToFavouritesPayload
import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload
import io.novafoundation.nova.feature_dapp_impl.presentation.browser.options.DAppOptionsPayload
import io.novafoundation.nova.feature_dapp_impl.presentation.common.favourites.RemoveFavouritesPayload
import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator
import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchRequester
import io.novafoundation.nova.feature_dapp_impl.presentation.search.SearchPayload
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.BrowserTabService
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.createAndSelectTab
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.CurrentTabState
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.stateId
import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session.Authorization.State
import io.novafoundation.nova.feature_dapp_impl.web3.states.ExtensionStoreFactory
import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3ExtensionStateMachine.ExternalEvent
import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3StateMachineHost
import io.novafoundation.nova.feature_dapp_impl.web3.states.hostApi.ConfirmTxResponse
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
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.ExternalSignRequest
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_external_sign_api.model.signPayload.polkadot.genesisHash
import io.novafoundation.nova.feature_external_sign_api.presentation.externalSign.AuthorizeDappBottomSheet
import io.novafoundation.nova.runtime.ext.isDisabled
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chainsById
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
enum class ConfirmationState {
ALLOWED, REJECTED, CANCELLED
}
data class DesktopModeChangedEvent(val desktopModeEnabled: Boolean, val url: String)
class DAppBrowserViewModel(
private val router: DAppRouter,
private val signRequester: ExternalSignRequester,
private val extensionStoreFactory: ExtensionStoreFactory,
private val dAppInteractor: DappInteractor,
private val interactor: DappBrowserInteractor,
private val dAppSearchRequester: DAppSearchRequester,
private val payload: DAppBrowserPayload,
private val selectedAccountUseCase: SelectedAccountUseCase,
private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
private val chainRegistry: ChainRegistry,
private val browserTabService: BrowserTabService
) : BaseViewModel(), Web3StateMachineHost {
val removeFromFavouritesConfirmation = actionAwaitableMixinFactory.confirmingAction<RemoveFavouritesPayload>()
private val _showConfirmationDialog = MutableLiveData<Event<DappPendingConfirmation<*>>>()
val showConfirmationSheet = _showConfirmationDialog
override val selectedAccount = selectedAccountUseCase.selectedMetaAccountFlow()
.share()
private val currentPage = singleReplaySharedFlow<BrowserPage>()
override val currentPageAnalyzed = currentPage.flatMapLatest {
interactor.observeBrowserPageFor(it)
}.shareInBackground()
override val externalEvents = singleReplaySharedFlow<ExternalEvent>()
private val _browserCommandEvent = MutableLiveData<Event<BrowserCommand>>()
val browserCommandEvent: LiveData<Event<BrowserCommand>> = _browserCommandEvent
private val _openBrowserOptionsEvent = MutableLiveData<Event<DAppOptionsPayload>>()
val openBrowserOptionsEvent: LiveData<Event<DAppOptionsPayload>> = _openBrowserOptionsEvent
val extensionsStore = extensionStoreFactory.create(hostApi = this, coroutineScope = this)
private val isDesktopModeEnabledFlow = MutableStateFlow(false)
val desktopModeChangedModel = currentPageAnalyzed
.map { currentPage ->
val hostSettings = interactor.getHostSettings(currentPage.url)
val isDesktopModeEnabled = hostSettings?.isDesktopModeEnabled ?: isDesktopModeEnabledFlow.first()
DesktopModeChangedEvent(isDesktopModeEnabled, currentPage.url)
}
.distinctUntilChanged()
.shareInBackground()
private val tabsState = browserTabService.tabStateFlow
.distinctUntilChangedBy { it.stateId() }
.shareInBackground()
val currentTabFlow = tabsState.map { it.selectedTab }
.distinctUntilChangedBy { it.stateId() }
.filterIsInstance<CurrentTabState.Selected>()
.shareInBackground()
val tabsCountFlow = tabsState.map { it.tabs.size }
.shareInBackground()
init {
dAppSearchRequester.responseFlow
.filterIsInstance<DAppSearchCommunicator.Response.NewUrl>()
.onEach { forceLoad(it.url) }
.launchIn(this)
watchDangerousWebsites()
launch {
when (payload) {
is DAppBrowserPayload.Tab -> browserTabService.selectTab(payload.id)
is DAppBrowserPayload.Address -> browserTabService.createAndSelectTab(payload.address)
}
}
}
override suspend fun authorizeDApp(payload: AuthorizeDappBottomSheet.Payload): State {
val confirmationState = awaitConfirmation(DappPendingConfirmation.Action.Authorize(payload))
return mapConfirmationStateToAuthorizationState(confirmationState)
}
override suspend fun confirmTx(request: ExternalSignRequest): ConfirmTxResponse {
val chainId = request.extractChainId()
val chain = chainRegistry.chainsById()[chainId]
if (chain != null && chain.isDisabled) {
return ConfirmTxResponse.ChainIsDisabled(request.id, chain.name)
}
val response = withContext(Dispatchers.Main) {
signRequester.awaitConfirmation(mapSignExtrinsicRequestToPayload(request))
}
return when (response) {
is ExternalSignCommunicator.Response.Rejected -> ConfirmTxResponse.Rejected(response.requestId)
is ExternalSignCommunicator.Response.Signed -> ConfirmTxResponse.Signed(response.requestId, response.signature, response.modifiedTransaction)
is ExternalSignCommunicator.Response.SigningFailed -> ConfirmTxResponse.SigningFailed(response.requestId, response.shouldPresent)
is ExternalSignCommunicator.Response.Sent -> ConfirmTxResponse.Sent(response.requestId, response.txHash)
}
}
override fun reloadPage() {
_browserCommandEvent.postValue(BrowserCommand.Reload.event())
}
fun detachCurrentSession() {
browserTabService.detachCurrentSession()
}
fun onPageChanged(url: String?, title: String?) {
updateCurrentPage(url ?: "", title, synchronizedWithBrowser = true)
}
fun closeClicked() = launch {
exitBrowser()
}
fun openSearch() = launch {
val currentPage = currentPage.first()
dAppSearchRequester.openRequest(SearchPayload(initialUrl = currentPage.url, SearchPayload.Request.GO_TO_URL))
}
fun onMoreClicked() {
launch {
val payload = getCurrentPageOptionsPayload()
_openBrowserOptionsEvent.value = Event(payload)
}
}
fun onFavoriteClick() {
launch {
val page = currentPageAnalyzed.first()
val currentPageTitle = page.title ?: page.display
val isCurrentPageFavorite = page.isFavourite
if (isCurrentPageFavorite) {
removeFromFavouritesConfirmation.awaitAction(currentPageTitle)
dAppInteractor.removeDAppFromFavourites(page.url)
} else {
val payload = AddToFavouritesPayload(
url = page.url,
label = currentPageTitle,
iconLink = null
)
router.openAddToFavourites(payload)
}
}
}
fun onDesktopClick() {
launch {
val desktopModeChangedEvent = desktopModeChangedModel.first()
val newDesktopMode = !desktopModeChangedEvent.desktopModeEnabled
val settings = BrowserHostSettings(Urls.normalizeUrl(desktopModeChangedEvent.url), newDesktopMode)
interactor.saveHostSettings(settings)
isDesktopModeEnabledFlow.value = newDesktopMode
_browserCommandEvent.postValue(BrowserCommand.ChangeDesktopMode(newDesktopMode).event())
}
}
fun openTabs() {
router.openTabs()
}
fun makePageSnapshot() {
browserTabService.makeCurrentTabSnapshot()
}
private fun watchDangerousWebsites() {
currentPageAnalyzed
.filter { it.synchronizedWithBrowser && it.security == BrowserPageAnalyzed.Security.DANGEROUS }
.distinctUntilChanged()
.onEach {
externalEvents.emit(ExternalEvent.PhishingDetected)
awaitConfirmation(DappPendingConfirmation.Action.AcknowledgePhishingAlert)
exitBrowser()
}
.launchIn(this)
}
private fun forceLoad(url: String) {
_browserCommandEvent.value = BrowserCommand.OpenUrl(url).event()
updateCurrentPage(url, title = null, synchronizedWithBrowser = false)
}
private suspend fun getCurrentPageOptionsPayload(): DAppOptionsPayload {
return DAppOptionsPayload(
isDesktopModeEnabled = desktopModeChangedModel.first().desktopModeEnabled
)
}
private suspend fun awaitConfirmation(action: DappPendingConfirmation.Action) = suspendCoroutine<ConfirmationState> {
val confirmation = DappPendingConfirmation(
onConfirm = { it.resume(ConfirmationState.ALLOWED) },
onDeny = { it.resume(ConfirmationState.REJECTED) },
onCancel = { it.resume(ConfirmationState.CANCELLED) },
action = action
)
_showConfirmationDialog.postValue(confirmation.event())
}
private fun mapConfirmationStateToAuthorizationState(
confirmationState: ConfirmationState
): State = when (confirmationState) {
ConfirmationState.ALLOWED -> State.ALLOWED
ConfirmationState.REJECTED -> State.REJECTED
ConfirmationState.CANCELLED -> State.NONE
}
private fun exitBrowser() = router.back()
private fun updateCurrentPage(
url: String,
title: String?,
synchronizedWithBrowser: Boolean
) = launch {
currentPage.emit(BrowserPage(url, title, synchronizedWithBrowser))
}
private suspend fun mapSignExtrinsicRequestToPayload(request: ExternalSignRequest): ExternalSignPayload {
return ExternalSignPayload(
signRequest = request,
dappMetadata = getDAppSignMetadata(currentPageAnalyzed.first().url),
wallet = ExternalSignWallet.Current
)
}
private suspend fun getDAppSignMetadata(dAppUrl: String): SigningDappMetadata {
val dappMetadata = dAppInteractor.getDAppInfo(dAppUrl)
return SigningDappMetadata(
icon = dappMetadata.metadata?.iconLink,
name = dappMetadata.metadata?.name,
url = dappMetadata.baseUrl,
)
}
private fun ExternalSignRequest.extractChainId(): String? {
return when (this) {
is ExternalSignRequest.Evm -> null
is ExternalSignRequest.Polkadot -> payload.genesisHash()?.removeHexPrefix()
}
}
}
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main
import io.novafoundation.nova.feature_external_sign_api.presentation.externalSign.AuthorizeDappBottomSheet
class DappPendingConfirmation<A : DappPendingConfirmation.Action>(
val onConfirm: () -> Unit,
val onDeny: () -> Unit,
val onCancel: () -> Unit,
val action: A
) {
sealed class Action {
class Authorize(val content: AuthorizeDappBottomSheet.Payload) : Action()
object AcknowledgePhishingAlert : Action()
}
}
@@ -0,0 +1,58 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.deeplink
import android.net.Uri
import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkHandlingException.DAppHandlingException
import io.novafoundation.nova.common.utils.Urls
import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate
import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed
import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.BrowserTabService
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.createAndSelectTab
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.hasSelectedTab
import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent
import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
private const val DAPP_DEEP_LINK_PREFIX = "/open/dapp"
class DAppDeepLinkHandler(
private val dappRepository: DAppMetadataRepository,
private val router: DAppRouter,
private val automaticInteractionGate: AutomaticInteractionGate,
private val browserTabService: BrowserTabService
) : DeepLinkHandler {
override val callbackFlow: Flow<CallbackEvent> = emptyFlow()
override suspend fun matches(data: Uri): Boolean {
val path = data.path ?: return false
return path.startsWith(DAPP_DEEP_LINK_PREFIX)
}
override suspend fun handleDeepLink(data: Uri) = runCatching {
automaticInteractionGate.awaitInteractionAllowed()
val url = data.getDappUrl() ?: throw DAppHandlingException.UrlIsInvalid
val normalizedUrl = runCatching { Urls.normalizeUrl(url) }.getOrNull() ?: throw DAppHandlingException.UrlIsInvalid
ensureDAppInCatalog(normalizedUrl)
if (browserTabService.hasSelectedTab()) {
browserTabService.createAndSelectTab(normalizedUrl)
} else {
router.openDAppBrowser(DAppBrowserPayload.Address(url))
}
}
private suspend fun ensureDAppInCatalog(normalizedUrl: String) {
dappRepository.syncAndGetDapp(normalizedUrl)
?: throw DAppHandlingException.DomainIsNotMatched(normalizedUrl)
}
private fun Uri.getDappUrl(): String? {
return getQueryParameter("url")
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.DAppBrowserFragment
import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload
@Subcomponent(
modules = [
DAppBrowserModule::class
]
)
@ScreenScope
interface DAppBrowserComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: DAppBrowserPayload
): DAppBrowserComponent
}
fun inject(fragment: DAppBrowserFragment)
}
@@ -0,0 +1,122 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.resources.ContextManager
import io.novafoundation.nova.common.utils.ToastMessageManager
import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserHostSettingsRepository
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload
import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository
import io.novafoundation.nova.feature_dapp_impl.data.repository.PhishingSitesRepository
import io.novafoundation.nova.feature_dapp_impl.data.repository.RealBrowserHostSettingsRepository
import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor
import io.novafoundation.nova.feature_dapp_impl.domain.browser.DappBrowserInteractor
import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.DAppBrowserViewModel
import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.BrowserTabService
import io.novafoundation.nova.feature_dapp_impl.web3.states.ExtensionStoreFactory
import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooserFactory
import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAskerFactory
import io.novafoundation.nova.common.utils.webView.WebViewRequestInterceptor
import io.novafoundation.nova.common.utils.webView.interceptors.CompoundWebViewRequestInterceptor
import io.novafoundation.nova.feature_dapp_impl.web3.webview.interceptors.WalletConnectPairingInterceptor
import io.novafoundation.nova.feature_dapp_impl.web3.webview.interceptors.Web3FallbackInterceptor
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class DAppBrowserModule {
@Provides
@ScreenScope
fun provideBrowserHostSettingsRepository(
browserHostSettingsDao: BrowserHostSettingsDao
): BrowserHostSettingsRepository = RealBrowserHostSettingsRepository(browserHostSettingsDao)
@Provides
@ScreenScope
fun provideInteractor(
phishingSitesRepository: PhishingSitesRepository,
favouritesDAppRepository: FavouritesDAppRepository,
browserHostSettingsRepository: BrowserHostSettingsRepository
) = DappBrowserInteractor(
phishingSitesRepository = phishingSitesRepository,
favouritesDAppRepository = favouritesDAppRepository,
browserHostSettingsRepository = browserHostSettingsRepository
)
@Provides
@ScreenScope
fun provideFileChooser(
fragment: Fragment,
webViewFileChooserFactory: WebViewFileChooserFactory
) = webViewFileChooserFactory.create(fragment)
@Provides
@ScreenScope
fun providePermissionAsker(
fragment: Fragment,
webViewPermissionAskerFactory: WebViewPermissionAskerFactory
) = webViewPermissionAskerFactory.create(fragment)
@Provides
@ScreenScope
fun provideWebViewClientInterceptor(
toastMessageManager: ToastMessageManager,
contextManager: ContextManager,
walletConnectService: WalletConnectService
): WebViewRequestInterceptor {
return CompoundWebViewRequestInterceptor(
WalletConnectPairingInterceptor(walletConnectService),
Web3FallbackInterceptor(toastMessageManager, contextManager)
)
}
@Provides
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): DAppBrowserViewModel {
return ViewModelProvider(fragment, factory).get(DAppBrowserViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(DAppBrowserViewModel::class)
fun provideViewModel(
router: DAppRouter,
interactor: DappBrowserInteractor,
selectedAccountUseCase: SelectedAccountUseCase,
signRequester: ExternalSignCommunicator,
searchRequester: DAppSearchCommunicator,
payload: DAppBrowserPayload,
extensionStoreFactory: ExtensionStoreFactory,
dAppInteractor: DappInteractor,
actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
chainRegistry: ChainRegistry,
browserTabService: BrowserTabService
): ViewModel {
return DAppBrowserViewModel(
router = router,
interactor = interactor,
dAppInteractor = dAppInteractor,
selectedAccountUseCase = selectedAccountUseCase,
signRequester = signRequester,
dAppSearchRequester = searchRequester,
payload = payload,
extensionStoreFactory = extensionStoreFactory,
actionAwaitableMixinFactory = actionAwaitableMixinFactory,
chainRegistry = chainRegistry,
browserTabService = browserTabService
)
}
}
@@ -0,0 +1,25 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.sheets
import android.content.Context
import android.os.Bundle
import io.novafoundation.nova.common.view.bottomSheet.ActionNotAllowedBottomSheet
import io.novafoundation.nova.feature_dapp_impl.R
import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.DappPendingConfirmation
class AcknowledgePhishingBottomSheet(
context: Context,
private val confirmation: DappPendingConfirmation<*>,
) : ActionNotAllowedBottomSheet(
context = context,
onSuccess = { confirmation.onConfirm() }
) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
titleView.setText(R.string.dapp_phishing_title)
subtitleView.setText(R.string.dapp_phishing_subtitle)
applySolidIconStyle(R.drawable.ic_warning_filled, tint = null)
}
}
@@ -0,0 +1,51 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.view
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.widget.LinearLayout
import io.novafoundation.nova.common.utils.WithContextExtensions
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.setImageTintRes
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.common.utils.setVisible
import io.novafoundation.nova.feature_dapp_impl.R
import io.novafoundation.nova.feature_dapp_impl.databinding.ViewAddressBarBinding
class AddressBarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr), WithContextExtensions {
private val binder = ViewAddressBarBinding.inflate(inflater(), this)
override val providedContext: Context = context
init {
orientation = HORIZONTAL
gravity = Gravity.CENTER
background = addRipple(getRoundedCornerDrawable(R.color.dapp_blur_navigation_background, cornerSizeDp = 10), mask = getRippleMask(cornerSizeDp = 10))
}
fun setAddress(address: String) {
binder.addressBarUrl.text = address
}
fun showSecureIcon(shouldShow: Boolean) {
binder.addressBarIcon.setVisible(shouldShow)
}
fun showSecure(shouldShow: Boolean) {
binder.addressBarIcon.setVisible(shouldShow)
if (shouldShow) {
binder.addressBarUrl.setTextColorRes(R.color.text_positive)
binder.addressBarIcon.setImageTintRes(R.color.icon_positive)
} else {
binder.addressBarUrl.setTextColorRes(R.color.text_primary)
binder.addressBarIcon.setImageTintRes(R.color.icon_primary)
}
}
}
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.browser.options
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class DAppOptionsPayload(
val isDesktopModeEnabled: Boolean
) : Parcelable
@@ -0,0 +1,34 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.browser.options
import android.content.Context
import android.view.View
import io.novafoundation.nova.common.databinding.BottomSheeetFixedListBinding
import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet
import io.novafoundation.nova.common.view.bottomSheet.list.fixed.switcherItem
import io.novafoundation.nova.feature_dapp_impl.R
class OptionsBottomSheetDialog(
context: Context,
private val callback: Callback,
private val payload: DAppOptionsPayload
) : FixedListBottomSheet<BottomSheeetFixedListBinding>(context, viewConfiguration = ViewConfiguration.default(context)) {
init {
setTitle(R.string.dapp_options_title)
switcherItem(
R.drawable.ic_desktop,
R.string.dapp_options_desktop_mode,
payload.isDesktopModeEnabled,
::toggleDesktopMode
)
}
private fun toggleDesktopMode(view: View) {
callback.onDesktopModeClick()
}
interface Callback {
fun onDesktopModeClick()
}
}
@@ -0,0 +1,5 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.common
interface DAppClickHandler {
fun onDAppClicked(item: DappModel)
}
@@ -0,0 +1,67 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.common
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearSnapHelper
import io.novafoundation.nova.common.list.BaseListAdapter
import io.novafoundation.nova.common.list.BaseViewHolder
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.recyclerView.WithViewType
import io.novafoundation.nova.feature_dapp_impl.R
import io.novafoundation.nova.feature_dapp_impl.databinding.ItemDappGroupBinding
class DappCategoryListAdapter(
private val handler: DAppClickHandler
) : BaseListAdapter<DappCategoryModel, DappCategoryViewHolder>(DappCategoryDiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DappCategoryViewHolder {
return DappCategoryViewHolder(ItemDappGroupBinding.inflate(parent.inflater(), parent, false), handler)
}
override fun onBindViewHolder(holder: DappCategoryViewHolder, position: Int) {
holder.bind(getItem(position))
}
override fun getItemViewType(position: Int): Int {
return DappCategoryViewHolder.viewType
}
}
private object DappCategoryDiffCallback : DiffUtil.ItemCallback<DappCategoryModel>() {
override fun areItemsTheSame(oldItem: DappCategoryModel, newItem: DappCategoryModel): Boolean {
return oldItem.categoryName == newItem.categoryName
}
override fun areContentsTheSame(oldItem: DappCategoryModel, newItem: DappCategoryModel): Boolean {
return oldItem == newItem
}
}
class DappCategoryViewHolder(
private val binder: ItemDappGroupBinding,
itemHandler: DAppClickHandler,
) : BaseViewHolder(binder.root) {
companion object : WithViewType {
override val viewType: Int = R.layout.item_dapp_group
}
private val adapter = DappListAdapter(itemHandler)
init {
binder.dappRecyclerView.layoutManager = GridLayoutManager(itemView.context, 3, GridLayoutManager.HORIZONTAL, false)
binder.dappRecyclerView.adapter = adapter
binder.dappRecyclerView.itemAnimator = null
val snapHelper = LinearSnapHelper()
snapHelper.attachToRecyclerView(binder.dappRecyclerView)
}
fun bind(item: DappCategoryModel) = with(binder) {
itemDAppCategoryTitle.text = item.categoryName
adapter.submitList(item.items)
}
override fun unbind() {}
}
@@ -0,0 +1,6 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.common
data class DappCategoryModel(
val categoryName: String,
val items: List<DappModel>
)
@@ -0,0 +1,38 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.common
import android.view.ViewGroup
import io.novafoundation.nova.common.list.BaseListAdapter
import io.novafoundation.nova.common.list.BaseViewHolder
import io.novafoundation.nova.feature_dapp_api.presentation.view.DAppView
class DappListAdapter(
private val handler: DAppClickHandler
) : BaseListAdapter<DappModel, DappViewHolder>(DappModelDiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DappViewHolder {
return DappViewHolder(DAppView.createUsingMathParentWidth(parent.context), handler)
}
override fun onBindViewHolder(holder: DappViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
class DappViewHolder(
private val dAppView: DAppView,
private val itemHandler: DAppClickHandler,
) : BaseViewHolder(dAppView) {
fun bind(item: DappModel) = with(dAppView) {
setTitle(item.name)
setSubtitle(item.description)
setIconUrl(item.iconUrl)
setFavoriteIconVisible(item.isFavourite)
setOnClickListener { itemHandler.onDAppClicked(item) }
}
override fun unbind() {
dAppView.clearIcon()
}
}
@@ -0,0 +1,65 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.common
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_dapp_api.data.model.DApp
import io.novafoundation.nova.feature_dapp_api.data.model.DAppGroupedCatalog
import io.novafoundation.nova.feature_dapp_api.data.model.DappCategory
import io.novafoundation.nova.feature_dapp_impl.R
import io.novafoundation.nova.feature_dapp_impl.data.model.FavouriteDApp
import io.novafoundation.nova.feature_dapp_impl.domain.common.dappToFavorite
import io.novafoundation.nova.feature_dapp_impl.domain.common.favouriteToDApp
data class DappModel(
val name: String,
val description: String,
val iconUrl: String?,
val isFavourite: Boolean,
val favoriteIndex: Int?,
val url: String
)
fun mapDappCategoriesToDescription(categories: Collection<DappCategory>) = categories.joinToString { it.name }
fun mapDAppCatalogToDAppCategoryModels(resourceManager: ResourceManager, dappCatalog: DAppGroupedCatalog): List<DappCategoryModel> {
val popular = mapDappCategoryToDappCategoryModel(resourceManager.getString(R.string.popular_dapps_title), dappCatalog.popular)
val categories = dappCatalog.categoriesWithDApps.map { (category, dapps) -> mapDappCategoryToDappCategoryModel(category.name, dapps) }
return listOf(popular) + categories
}
fun mapDappCategoryToDappCategoryModel(categoryName: String, dApps: List<DApp>) = DappCategoryModel(
categoryName = categoryName,
items = dApps.map { mapDappToDappModel(it) }
)
fun mapDappToDappModel(dApp: DApp) = with(dApp) {
DappModel(
name = name,
description = description,
iconUrl = iconLink,
url = url,
isFavourite = isFavourite,
favoriteIndex = favoriteIndex
)
}
fun mapDappModelToDApp(dApp: DappModel) = with(dApp) {
DApp(
name = name,
description = description,
iconLink = iconUrl,
url = url,
isFavourite = isFavourite,
favoriteIndex = favoriteIndex
)
}
fun mapFavoriteDappToDappModel(favoriteDapp: FavouriteDApp): DappModel {
val dapp = favouriteToDApp(favoriteDapp)
return mapDappToDappModel(dapp)
}
fun mapDAppModelToFavorite(model: DappModel, orderingIndex: Int): FavouriteDApp {
val dapp = mapDappModelToDApp(model)
return dappToFavorite(dapp, orderingIndex)
}
@@ -0,0 +1,14 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.common
import androidx.recyclerview.widget.DiffUtil
object DappModelDiffCallback : DiffUtil.ItemCallback<DappModel>() {
override fun areItemsTheSame(oldItem: DappModel, newItem: DappModel): Boolean {
return oldItem.url == newItem.url
}
override fun areContentsTheSame(oldItem: DappModel, newItem: DappModel): Boolean {
return oldItem == newItem
}
}
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.common
import io.novafoundation.nova.feature_dapp_api.data.model.DappCategory
import io.novafoundation.nova.feature_dapp_impl.presentation.main.model.DAppCategoryModel
fun dappCategoryToUi(dappCategory: DappCategory, isSelected: Boolean): DAppCategoryModel {
return DAppCategoryModel(
id = dappCategory.id,
name = dappCategory.name,
selected = isSelected,
iconUrl = dappCategory.iconUrl
)
}
@@ -0,0 +1,23 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.common.favourites
import io.novafoundation.nova.common.base.BaseFragmentMixin
import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationAwaitable
import io.novafoundation.nova.common.view.dialog.warningDialog
import io.novafoundation.nova.feature_dapp_impl.R
typealias RemoveFavouritesPayload = String // dApp title
fun BaseFragmentMixin<*>.setupRemoveFavouritesConfirmation(awaitableMixin: ConfirmationAwaitable<RemoveFavouritesPayload>) {
awaitableMixin.awaitableActionLiveData.observeEvent {
warningDialog(
context = providedContext,
onPositiveClick = { it.onSuccess(Unit) },
positiveTextRes = R.string.common_remove,
onNegativeClick = it.onCancel
) {
setTitle(R.string.dapp_favourites_remove_title)
setMessage(providedContext.getString(R.string.dapp_favourites_remove_description, it.payload))
}
}
}
@@ -0,0 +1,67 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.favorites
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction
import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload
import io.novafoundation.nova.feature_dapp_impl.data.model.FavouriteDApp
import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel
import io.novafoundation.nova.feature_dapp_impl.presentation.common.favourites.RemoveFavouritesPayload
import io.novafoundation.nova.feature_dapp_impl.presentation.common.mapDAppModelToFavorite
import io.novafoundation.nova.feature_dapp_impl.presentation.common.mapFavoriteDappToDappModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class DAppFavoritesViewModel(
private val router: DAppRouter,
private val interactor: DappInteractor,
private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
) : BaseViewModel() {
val removeFavouriteConfirmationAwaitable = actionAwaitableMixinFactory.confirmingAction<RemoveFavouritesPayload>()
private val favoriteDAppsFlow = MutableStateFlow<List<FavouriteDApp>>(emptyList())
val favoriteDAppsUIFlow = favoriteDAppsFlow
.map { dapps -> dapps.map { mapFavoriteDappToDappModel(it) } }
.shareInBackground()
init {
launch {
updateDApps()
}
}
fun backClicked() {
router.back()
}
fun openDApp(dapp: DappModel) {
router.openDAppBrowser(DAppBrowserPayload.Address(dapp.url))
}
fun onFavoriteClicked(dapp: DappModel) = launch {
removeFavouriteConfirmationAwaitable.awaitAction(dapp.name)
interactor.removeDAppFromFavourites(dapp.url)
// Update list, since item was removed
updateDApps()
}
fun changeDAppOrdering(newOrdering: List<DappModel>) = launch {
val favoriteItems = newOrdering.mapIndexed { index, dappModel ->
mapDAppModelToFavorite(dappModel, index)
}
interactor.updateFavoriteDapps(favoriteItems)
}
private suspend fun updateDApps() {
val dapps = interactor.getFavoriteDApps()
favoriteDAppsFlow.value = dapps
}
}
@@ -0,0 +1,82 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.favorites
import android.annotation.SuppressLint
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import coil.ImageLoader
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.recyclerView.dragging.OnItemDragCallback
import io.novafoundation.nova.common.utils.recyclerView.dragging.StartDragListener
import io.novafoundation.nova.common.utils.recyclerView.dragging.prepareForDragging
import io.novafoundation.nova.feature_dapp_impl.databinding.ItemDappFavoriteDragableBinding
import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel
import io.novafoundation.nova.feature_external_sign_api.presentation.dapp.showDAppIcon
import java.util.Collections
class DappDraggableFavoritesAdapter(
private val imageLoader: ImageLoader,
private val handler: Handler,
private val startDragListener: StartDragListener
) : RecyclerView.Adapter<DappDraggableFavoritesViewHolder>(), OnItemDragCallback {
interface Handler {
fun onDAppClicked(dapp: DappModel)
fun onDAppFavoriteClicked(dapp: DappModel)
fun onItemOrderingChanged(dapps: List<DappModel>)
}
private val dapps = mutableListOf<DappModel>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DappDraggableFavoritesViewHolder {
return DappDraggableFavoritesViewHolder(
ItemDappFavoriteDragableBinding.inflate(parent.inflater(), parent, false),
imageLoader,
handler,
startDragListener
)
}
override fun getItemCount(): Int {
return dapps.size
}
override fun onBindViewHolder(holder: DappDraggableFavoritesViewHolder, position: Int) {
holder.bind(dapps[position])
}
override fun onItemMove(fromPosition: Int, toPosition: Int) {
Collections.swap(dapps, fromPosition, toPosition)
notifyItemMoved(toPosition, fromPosition)
handler.onItemOrderingChanged(dapps)
}
@SuppressLint("NotifyDataSetChanged")
fun submitList(dapps: List<DappModel>) {
this.dapps.clear()
this.dapps.addAll(dapps)
notifyDataSetChanged()
}
}
class DappDraggableFavoritesViewHolder(
private val binder: ItemDappFavoriteDragableBinding,
private val imageLoader: ImageLoader,
private val itemHandler: DappDraggableFavoritesAdapter.Handler,
private val startDragListener: StartDragListener
) : ViewHolder(binder.root) {
@SuppressLint("ClickableViewAccessibility")
fun bind(item: DappModel) = with(itemView) {
binder.itemDraggableFavoriteDAppIcon.showDAppIcon(item.iconUrl, imageLoader)
binder.itemDraggableFavoriteDAppTitle.text = item.name
binder.itemDraggableFavoriteDAppSubtitle.text = item.description
binder.itemDraggableFavoriteDappDragHandle.prepareForDragging(this@DappDraggableFavoritesViewHolder, startDragListener)
binder.itemDraggableFavoriteDappFavoriteIcon.setOnClickListener { itemHandler.onDAppFavoriteClicked(item) }
setOnClickListener { itemHandler.onDAppClicked(item) }
}
}
@@ -0,0 +1,69 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.favorites
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseBottomSheetFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets
import io.novafoundation.nova.common.utils.recyclerView.dragging.SimpleItemDragHelperCallback
import io.novafoundation.nova.common.utils.recyclerView.dragging.StartDragListener
import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi
import io.novafoundation.nova.feature_dapp_impl.databinding.FragmentFavoritesDappBinding
import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureComponent
import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel
import io.novafoundation.nova.feature_dapp_impl.presentation.common.favourites.setupRemoveFavouritesConfirmation
import javax.inject.Inject
class DappFavoritesFragment :
BaseBottomSheetFragment<DAppFavoritesViewModel, FragmentFavoritesDappBinding>(),
DappDraggableFavoritesAdapter.Handler,
StartDragListener {
override fun createBinding() = FragmentFavoritesDappBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
private val adapter by lazy(LazyThreadSafetyMode.NONE) { DappDraggableFavoritesAdapter(imageLoader, this, this) }
private val itemDragHelper by lazy(LazyThreadSafetyMode.NONE) { ItemTouchHelper(SimpleItemDragHelperCallback(adapter)) }
override fun initViews() {
binder.favoritesDappToolbar.applyStatusBarInsets()
binder.favoritesDappToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.favoritesDappList.adapter = adapter
itemDragHelper.attachToRecyclerView(binder.favoritesDappList)
}
override fun inject() {
FeatureUtils.getFeature<DAppFeatureComponent>(this, DAppFeatureApi::class.java)
.dAppFavoritesComponentFactory()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: DAppFavoritesViewModel) {
setupRemoveFavouritesConfirmation(viewModel.removeFavouriteConfirmationAwaitable)
viewModel.favoriteDAppsUIFlow.observe {
adapter.submitList(it)
}
}
override fun onDAppClicked(dapp: DappModel) {
viewModel.openDApp(dapp)
}
override fun onDAppFavoriteClicked(dapp: DappModel) {
viewModel.onFavoriteClicked(dapp)
}
override fun onItemOrderingChanged(dapps: List<DappModel>) {
viewModel.changeDAppOrdering(dapps)
}
override fun requestDrag(viewHolder: RecyclerView.ViewHolder) {
itemDragHelper.startDrag(viewHolder)
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.favorites.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_dapp_impl.presentation.favorites.DappFavoritesFragment
@Subcomponent(
modules = [
DAppFavoritesModule::class
]
)
@ScreenScope
interface DAppFavoritesComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment
): DAppFavoritesComponent
}
fun inject(fragment: DappFavoritesFragment)
}
@@ -0,0 +1,38 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.favorites.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor
import io.novafoundation.nova.feature_dapp_impl.presentation.favorites.DAppFavoritesViewModel
@Module(includes = [ViewModelModule::class])
class DAppFavoritesModule {
@Provides
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): DAppFavoritesViewModel {
return ViewModelProvider(fragment, factory).get(DAppFavoritesViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(DAppFavoritesViewModel::class)
fun provideViewModel(
router: DAppRouter,
interactor: DappInteractor,
actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
): ViewModel {
return DAppFavoritesViewModel(
router,
interactor,
actionAwaitableMixinFactory
)
}
}
@@ -0,0 +1,134 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.main
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.setVisible
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedWalletModel
import io.novafoundation.nova.feature_dapp_impl.databinding.ItemDappHeaderBinding
import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel
import io.novafoundation.nova.feature_dapp_impl.presentation.main.model.DAppCategoryModel
class DAppHeaderAdapter(
val imageLoader: ImageLoader,
val headerHandler: Handler,
val categoriesHandler: DappCategoriesAdapter.Handler
) : RecyclerView.Adapter<HeaderHolder>() {
private var walletModel: SelectedWalletModel? = null
private var categories: List<DAppCategoryModel> = emptyList()
private var favoritesDApps: List<DappModel> = emptyList()
private var showCategoriesShimmering: Boolean = false
interface Handler {
fun onWalletClick()
fun onSearchClick()
fun onManageClick()
fun onManageFavoritesClick()
fun onCategoryClicked(id: String)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderHolder {
return HeaderHolder(
imageLoader,
ItemDappHeaderBinding.inflate(parent.inflater(), parent, false),
headerHandler,
categoriesHandler
)
}
override fun onBindViewHolder(holder: HeaderHolder, position: Int) {
holder.bind(
walletModel,
categories,
favoritesDApps,
showCategoriesShimmering
)
}
override fun onBindViewHolder(holder: HeaderHolder, position: Int, payloads: MutableList<Any>) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position)
} else {
payloads.filterIsInstance<Payload>().forEach {
when (it) {
Payload.WALLET -> holder.bindWallet(walletModel)
Payload.CATEGORIES -> holder.bindCategories(categories)
Payload.CATEGORIES_SHIMMERING -> holder.bindCategoriesShimmering(showCategoriesShimmering)
}
}
}
}
override fun getItemCount(): Int {
return 1
}
fun setWallet(walletModel: SelectedWalletModel) {
this.walletModel = walletModel
notifyItemChanged(0, Payload.WALLET)
}
fun setCategories(categories: List<DAppCategoryModel>) {
this.categories = categories
notifyItemChanged(0, Payload.CATEGORIES)
}
fun showCategoriesShimmering(show: Boolean) {
showCategoriesShimmering = show
notifyItemChanged(0, Payload.CATEGORIES_SHIMMERING)
}
}
class HeaderHolder(
private val imageLoader: ImageLoader,
private val binder: ItemDappHeaderBinding,
headerHandler: DAppHeaderAdapter.Handler,
categoriesHandler: DappCategoriesAdapter.Handler
) : RecyclerView.ViewHolder(binder.root) {
private val categoriesAdapter = DappCategoriesAdapter(imageLoader, categoriesHandler)
init {
binder.dappMainSelectedWallet.setOnClickListener { headerHandler.onWalletClick() }
binder.dappMainSearch.setOnClickListener { headerHandler.onSearchClick() }
binder.dappMainManage.setOnClickListener { headerHandler.onManageClick() }
binder.mainDappCategories.adapter = categoriesAdapter
}
fun bind(
walletModel: SelectedWalletModel?,
categoriesState: List<DAppCategoryModel>,
favoritesDApps: List<DappModel>,
showCategoriesShimmering: Boolean
) {
bindWallet(walletModel)
bindCategories(categoriesState)
bindCategoriesShimmering(showCategoriesShimmering)
}
fun bindWallet(walletModel: SelectedWalletModel?) = with(binder) {
walletModel?.let { dappMainSelectedWallet.setModel(walletModel) }
}
fun bindCategories(categoriesState: List<DAppCategoryModel>) = with(binder) {
categoriesAdapter.submitList(categoriesState)
}
fun bindCategoriesShimmering(showCategoriesShimmering: Boolean) = with(itemView) {
binder.categorizedDappsCategoriesShimmering.setVisible(showCategoriesShimmering, falseState = View.INVISIBLE)
binder.mainDappCategories.isInvisible = showCategoriesShimmering
}
}
private enum class Payload {
WALLET, CATEGORIES, CATEGORIES_SHIMMERING
}
@@ -0,0 +1,88 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.main
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import io.novafoundation.nova.common.list.PayloadGenerator
import io.novafoundation.nova.common.list.resolvePayload
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.loadOrHide
import io.novafoundation.nova.feature_dapp_impl.R
import io.novafoundation.nova.feature_dapp_impl.databinding.ItemDappCategoryBinding
import io.novafoundation.nova.feature_dapp_impl.presentation.main.model.DAppCategoryModel
class DappCategoriesAdapter(
private val imageLoader: ImageLoader,
private val handler: Handler,
) : ListAdapter<DAppCategoryModel, DappCategoryViewHolder>(DappDiffCallback) {
interface Handler {
fun onCategoryClicked(id: String)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DappCategoryViewHolder {
return DappCategoryViewHolder(ItemDappCategoryBinding.inflate(parent.inflater(), parent, false), imageLoader, handler)
}
override fun onBindViewHolder(holder: DappCategoryViewHolder, position: Int, payloads: MutableList<Any>) {
val item = getItem(position)
resolvePayload(holder, position, payloads) {
when (it) {
DAppCategoryModel::selected -> holder.bindSelected(item.selected)
}
}
}
override fun onBindViewHolder(holder: DappCategoryViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
private val dAppCategoryPayloadGenerator = PayloadGenerator(DAppCategoryModel::selected)
private object DappDiffCallback : DiffUtil.ItemCallback<DAppCategoryModel>() {
override fun areItemsTheSame(oldItem: DAppCategoryModel, newItem: DAppCategoryModel): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: DAppCategoryModel, newItem: DAppCategoryModel): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: DAppCategoryModel, newItem: DAppCategoryModel): Any? {
return dAppCategoryPayloadGenerator.diff(oldItem, newItem)
}
}
class DappCategoryViewHolder(
private val binder: ItemDappCategoryBinding,
private val imageLoader: ImageLoader,
private val itemHandler: DappCategoriesAdapter.Handler,
) : RecyclerView.ViewHolder(binder.root) {
fun bind(item: DAppCategoryModel) = with(binder) {
itemDappCategoryIcon.loadOrHide(item.iconUrl, imageLoader)
itemDappCategoryText.text = item.name
bindSelected(item.selected)
binder.root.setOnClickListener { itemHandler.onCategoryClicked(item.id) }
}
fun bindSelected(isSelected: Boolean) = with(binder) {
root.isSelected = isSelected
// We must set tint to image view programmatically since we can't specify the state for default color in state list
if (isSelected) {
itemDappCategoryIcon.setColorFilter(ContextCompat.getColor(itemView.context, R.color.icon_primary_on_content))
} else {
itemDappCategoryIcon.clearColorFilter()
}
}
}
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.main
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import coil.ImageLoader
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.feature_dapp_impl.databinding.ItemFavoriteDappBinding
import io.novafoundation.nova.feature_dapp_impl.presentation.common.DAppClickHandler
import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel
import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModelDiffCallback
import io.novafoundation.nova.feature_external_sign_api.presentation.dapp.showDAppIcon
class DappFavoritesAdapter(
private val imageLoader: ImageLoader,
private val handler: DAppClickHandler
) : ListAdapter<DappModel, FavoriteDappViewHolder>(DappModelDiffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FavoriteDappViewHolder {
return FavoriteDappViewHolder(ItemFavoriteDappBinding.inflate(parent.inflater(), parent, false), imageLoader, handler)
}
override fun onBindViewHolder(holder: FavoriteDappViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
class FavoriteDappViewHolder(
private val binder: ItemFavoriteDappBinding,
private val imageLoader: ImageLoader,
private val itemHandler: DAppClickHandler,
) : ViewHolder(binder.root) {
fun bind(item: DappModel) = with(binder) {
itemFavoriteDAppIcon.showDAppIcon(item.iconUrl, imageLoader)
itemFavoriteDAppTitle.text = item.name
binder.root.setOnClickListener { itemHandler.onDAppClicked(item) }
}
}
@@ -0,0 +1,132 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.main
import android.view.View
import androidx.recyclerview.widget.ConcatAdapter
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.list.CustomPlaceholderAdapter
import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents
import io.novafoundation.nova.common.presentation.LoadingState
import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets
import io.novafoundation.nova.common.utils.recyclerView.space.SpaceBetween
import io.novafoundation.nova.common.utils.recyclerView.space.addSpaceItemDecoration
import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannerAdapter
import io.novafoundation.nova.feature_banners_api.presentation.bindWithAdapter
import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi
import io.novafoundation.nova.feature_dapp_impl.R
import io.novafoundation.nova.feature_dapp_impl.databinding.FragmentDappMainBinding
import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureComponent
import io.novafoundation.nova.feature_dapp_impl.presentation.common.DAppClickHandler
import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappCategoryListAdapter
import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappCategoryViewHolder
import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel
import javax.inject.Inject
class MainDAppFragment :
BaseFragment<MainDAppViewModel, FragmentDappMainBinding>(),
DAppClickHandler,
DAppHeaderAdapter.Handler,
DappCategoriesAdapter.Handler,
MainFavoriteDAppsAdapter.Handler {
override fun createBinding() = FragmentDappMainBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
private val headerAdapter by lazy(LazyThreadSafetyMode.NONE) { DAppHeaderAdapter(imageLoader, this, this) }
private val bannerAdapter: PromotionBannerAdapter by lazy(LazyThreadSafetyMode.NONE) { PromotionBannerAdapter(closable = false) }
private val favoritesAdapter: MainFavoriteDAppsAdapter by lazy(LazyThreadSafetyMode.NONE) { MainFavoriteDAppsAdapter(this, this, imageLoader) }
private val dappsShimmering by lazy(LazyThreadSafetyMode.NONE) { CustomPlaceholderAdapter(R.layout.layout_dapps_shimmering) }
private val dappCategoriesListAdapter by lazy(LazyThreadSafetyMode.NONE) { DappCategoryListAdapter(this) }
override fun applyInsets(rootView: View) {
binder.dappRecyclerViewCatalog.applyStatusBarInsets()
}
override fun initViews() {
binder.dappRecyclerViewCatalog.adapter = ConcatAdapter(headerAdapter, bannerAdapter, favoritesAdapter, dappsShimmering, dappCategoriesListAdapter)
binder.dappRecyclerViewCatalog.itemAnimator = null
setupRecyclerViewSpacing()
}
override fun inject() {
FeatureUtils.getFeature<DAppFeatureComponent>(this, DAppFeatureApi::class.java)
.mainComponentFactory()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: MainDAppViewModel) {
observeBrowserEvents(viewModel)
viewModel.bannersMixin.bindWithAdapter(bannerAdapter) {
binder.dappRecyclerViewCatalog?.invalidateItemDecorations()
}
viewModel.selectedWalletFlow.observe(headerAdapter::setWallet)
viewModel.shownDAppsStateFlow.observe { state ->
when (state) {
is LoadingState.Loaded -> {
dappsShimmering.show(false)
dappCategoriesListAdapter.submitList(state.data)
}
is LoadingState.Loading -> {
dappsShimmering.show(true)
dappCategoriesListAdapter.submitList(listOf())
}
else -> {}
}
}
viewModel.categoriesStateFlow.observe { state ->
headerAdapter.showCategoriesShimmering(state is LoadingState.Loading)
if (state is LoadingState.Loaded) {
headerAdapter.setCategories(state.data.categories)
}
}
viewModel.favoriteDAppsUIFlow.observe {
favoritesAdapter.show(it.isNotEmpty())
favoritesAdapter.setDApps(it)
}
}
override fun onCategoryClicked(id: String) {
viewModel.openCategory(id)
}
override fun onDAppClicked(item: DappModel) {
viewModel.dappClicked(item)
}
override fun onWalletClick() {
viewModel.accountIconClicked()
}
override fun onSearchClick() {
viewModel.searchClicked()
}
override fun onManageClick() {
viewModel.manageClicked()
}
override fun onManageFavoritesClick() {
viewModel.openFavorites()
}
private fun setupRecyclerViewSpacing() {
binder.dappRecyclerViewCatalog.addSpaceItemDecoration {
// Add extra space between items
add(SpaceBetween(DappCategoryViewHolder, spaceDp = 8))
}
}
}
@@ -0,0 +1,110 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.main
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.api.Browserable
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.inBackground
import io.novafoundation.nova.common.utils.indexOfFirstOrNull
import io.novafoundation.nova.common.utils.withLoading
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory
import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory
import io.novafoundation.nova.feature_banners_api.presentation.source.dappsSource
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload
import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor
import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel
import io.novafoundation.nova.feature_dapp_impl.presentation.common.dappCategoryToUi
import io.novafoundation.nova.feature_dapp_impl.presentation.common.mapDAppCatalogToDAppCategoryModels
import io.novafoundation.nova.feature_dapp_impl.presentation.common.mapFavoriteDappToDappModel
import io.novafoundation.nova.feature_dapp_impl.presentation.main.model.DAppCategoryState
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class MainDAppViewModel(
private val promotionBannersMixinFactory: PromotionBannersMixinFactory,
private val bannerSourceFactory: BannersSourceFactory,
private val router: DAppRouter,
private val selectedAccountUseCase: SelectedAccountUseCase,
private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
private val dappInteractor: DappInteractor,
private val resourceManager: ResourceManager
) : BaseViewModel(), Browserable {
override val openBrowserEvent = MutableLiveData<Event<String>>()
val selectedWalletFlow = selectedAccountUseCase.selectedWalletModelFlow()
.shareInBackground()
val bannersMixin = promotionBannersMixinFactory.create(bannerSourceFactory.dappsSource(), viewModelScope)
private val favoriteDAppsFlow = dappInteractor.observeFavoriteDApps()
.shareInBackground()
private val groupedDAppsFlow = dappInteractor.observeDAppsByCategory()
.inBackground()
.share()
private val groupedDAppsUiFlow = groupedDAppsFlow
.map { mapDAppCatalogToDAppCategoryModels(resourceManager, it) }
.inBackground()
.share()
val favoriteDAppsUIFlow = favoriteDAppsFlow
.map { dapps -> dapps.map { mapFavoriteDappToDappModel(it) } }
.shareInBackground()
val shownDAppsStateFlow = groupedDAppsUiFlow
.filterNotNull()
.withLoading()
.share()
val categoriesStateFlow = groupedDAppsFlow
.map { catalog -> catalog.categoriesWithDApps.keys.map { dappCategoryToUi(it, isSelected = false) } }
.map { categories ->
DAppCategoryState(
categories = categories,
selectedIndex = categories.indexOfFirstOrNull { it.selected }
)
}
.inBackground()
.withLoading()
.share()
init {
syncDApps()
}
fun openCategory(categoryId: String) {
router.openDappSearchWithCategory(categoryId)
}
fun accountIconClicked() {
router.openChangeAccount()
}
fun dappClicked(dapp: DappModel) {
router.openDAppBrowser(DAppBrowserPayload.Address(dapp.url))
}
fun searchClicked() {
router.openDappSearch()
}
fun manageClicked() {
router.openAuthorizedDApps()
}
private fun syncDApps() = launch {
dappInteractor.dAppsSync()
}
fun openFavorites() {
router.openDAppFavorites()
}
}
@@ -0,0 +1,61 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.main
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import io.novafoundation.nova.common.list.SingleItemAdapter
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.feature_dapp_impl.databinding.ItemMainFavoriteDappsBinding
import io.novafoundation.nova.feature_dapp_impl.presentation.common.DAppClickHandler
import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel
class MainFavoriteDAppsAdapter(
private val dappClickHandler: DAppClickHandler,
private val handler: Handler,
private val imageLoader: ImageLoader
) : SingleItemAdapter<FavoriteDAppHolder>(isShownByDefault = true) {
interface Handler {
fun onManageFavoritesClick()
}
private var favoritesDApps: List<DappModel> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FavoriteDAppHolder {
val binder = ItemMainFavoriteDappsBinding.inflate(parent.inflater(), parent, false)
return FavoriteDAppHolder(binder, imageLoader, dappClickHandler, handler)
}
override fun onBindViewHolder(holder: FavoriteDAppHolder, position: Int) {
holder.bind(favoritesDApps)
}
fun setDApps(dapps: List<DappModel>) {
favoritesDApps = dapps
notifyChangedIfShown()
}
}
class FavoriteDAppHolder(
private val binder: ItemMainFavoriteDappsBinding,
imageLoader: ImageLoader,
dAppClickHandler: DAppClickHandler,
handler: MainFavoriteDAppsAdapter.Handler
) : RecyclerView.ViewHolder(binder.root) {
private val favoritesAdapter = DappFavoritesAdapter(imageLoader, dAppClickHandler)
init {
binder.dAppMainFavoriteDAppList.adapter = favoritesAdapter
binder.dAppMainFavoriteDAppsShow.setOnClickListener { handler.onManageFavoritesClick() }
}
fun bind(dapps: List<DappModel>) = with(binder) {
dAppMainFavoriteDAppList.isGone = dapps.isEmpty()
dAppMainFavoriteDAppTitle.isGone = dapps.isEmpty()
dAppMainFavoriteDAppsShow.isGone = dapps.isEmpty()
favoritesAdapter.submitList(dapps)
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.main.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_dapp_impl.presentation.main.MainDAppFragment
@Subcomponent(
modules = [
MainDAppModule::class
]
)
@ScreenScope
interface MainDAppComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment
): MainDAppComponent
}
fun inject(fragment: MainDAppFragment)
}
@@ -0,0 +1,50 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.main.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory
import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor
import io.novafoundation.nova.feature_dapp_impl.presentation.main.MainDAppViewModel
@Module(includes = [ViewModelModule::class])
class MainDAppModule {
@Provides
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): MainDAppViewModel {
return ViewModelProvider(fragment, factory).get(MainDAppViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(MainDAppViewModel::class)
fun provideViewModel(
promotionBannersMixinFactory: PromotionBannersMixinFactory,
bannerSourceFactory: BannersSourceFactory,
selectedAccountUseCase: SelectedAccountUseCase,
actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
router: DAppRouter,
dappInteractor: DappInteractor,
resourceManager: ResourceManager
): ViewModel {
return MainDAppViewModel(
promotionBannersMixinFactory = promotionBannersMixinFactory,
bannerSourceFactory = bannerSourceFactory,
router = router,
selectedAccountUseCase = selectedAccountUseCase,
actionAwaitableMixinFactory = actionAwaitableMixinFactory,
dappInteractor = dappInteractor,
resourceManager = resourceManager
)
}
}
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.main.model
data class DAppCategoryModel(
val id: String,
val iconUrl: String?,
val name: String,
val selected: Boolean
)
class DAppCategoryState(
val categories: List<DAppCategoryModel>,
val selectedIndex: Int?
)
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.main.view
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.view.ContextThemeWrapper
import androidx.appcompat.widget.AppCompatTextView
import io.novafoundation.nova.common.utils.WithContextExtensions
import io.novafoundation.nova.common.utils.setDrawableStart
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.feature_dapp_impl.R
class TapToSearchView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : AppCompatTextView(ContextThemeWrapper(context, R.style.TextAppearance_NovaFoundation_Regular_SubHeadline), attrs, defStyleAttr),
WithContextExtensions {
override val providedContext: Context
get() = context
init {
setPaddingRelative(12.dp, 0.dp, 12.dp, 0.dp)
gravity = android.view.Gravity.CENTER_VERTICAL
setDrawableStart(
drawableRes = R.drawable.ic_search,
widthInDp = 16,
heightInDp = 16,
paddingInDp = 6,
tint = R.color.icon_secondary
)
text = context.getString(R.string.dapp_search_hint)
setTextColorRes(R.color.hint_text)
background = addRipple(getRoundedCornerDrawable(R.color.block_background))
}
}
@@ -0,0 +1,23 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.search
import android.os.Parcelable
import io.novafoundation.nova.common.navigation.InterScreenRequester
import io.novafoundation.nova.common.navigation.InterScreenResponder
import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator.Response
import kotlinx.parcelize.Parcelize
interface DAppSearchRequester : InterScreenRequester<SearchPayload, Response>
interface DAppSearchResponder : InterScreenResponder<SearchPayload, Response>
interface DAppSearchCommunicator : DAppSearchRequester, DAppSearchResponder {
sealed interface Response : Parcelable {
@Parcelize
class NewUrl(val url: String) : Response
@Parcelize
object Cancel : Response
}
}
@@ -0,0 +1,159 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.search
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.data.network.AppLinksProvider
import io.novafoundation.nova.common.list.headers.TextHeader
import io.novafoundation.nova.common.list.toListWithHeaders
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.inBackground
import io.novafoundation.nova.common.utils.sendEvent
import io.novafoundation.nova.common.utils.withSafeLoading
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload
import io.novafoundation.nova.feature_dapp_impl.R
import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor
import io.novafoundation.nova.feature_dapp_impl.domain.search.DappSearchGroup
import io.novafoundation.nova.feature_dapp_impl.domain.search.DappSearchResult
import io.novafoundation.nova.feature_dapp_impl.domain.search.SearchDappInteractor
import io.novafoundation.nova.feature_dapp_impl.presentation.common.dappCategoryToUi
import io.novafoundation.nova.feature_dapp_impl.presentation.search.model.DappSearchModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
class DAppSearchViewModel(
private val router: DAppRouter,
private val resourceManager: ResourceManager,
private val interactor: SearchDappInteractor,
private val dappInteractor: DappInteractor,
private val payload: SearchPayload,
private val dAppSearchResponder: DAppSearchResponder,
private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
private val appLinksProvider: AppLinksProvider
) : BaseViewModel() {
val dAppNotInCatalogWarning = actionAwaitableMixinFactory.confirmingAction<DappUnknownWarningModel>()
val query = MutableStateFlow(payload.initialUrl.orEmpty())
private val _selectQueryTextEvent = MutableLiveData<Event<Unit>>()
val selectQueryTextEvent: LiveData<Event<Unit>> = _selectQueryTextEvent
private val selectedCategoryId = MutableStateFlow(payload.preselectedCategoryId)
val categoriesFlow = combine(
interactor.categories(),
selectedCategoryId
) { categories, categoryId ->
categories.map { dappCategoryToUi(it, isSelected = it.id == categoryId) }
}
.distinctUntilChanged()
.withSafeLoading()
.inBackground()
.share()
val searchResults = combine(query, selectedCategoryId) { query, categoryId ->
interactor.searchDapps(query, categoryId)
.mapKeys { (searchGroup, _) -> mapSearchGroupToTextHeader(searchGroup) }
.mapValues { (_, groupItems) -> groupItems.map(::mapSearchResultToSearchModel) }
.toListWithHeaders()
}
.inBackground()
.share()
init {
if (!payload.initialUrl.isNullOrEmpty()) {
_selectQueryTextEvent.sendEvent()
}
launch {
dappInteractor.dAppsSync()
}
}
fun cancelClicked() {
if (shouldReportResult()) {
dAppSearchResponder.respond(DAppSearchCommunicator.Response.Cancel)
}
router.back()
}
private fun mapSearchGroupToTextHeader(searchGroup: DappSearchGroup): TextHeader {
val content = when (searchGroup) {
DappSearchGroup.DAPPS -> resourceManager.getString(R.string.dapp_dapps)
DappSearchGroup.SEARCH -> resourceManager.getString(R.string.common_search)
}
return TextHeader(content)
}
private fun mapSearchResultToSearchModel(searchResult: DappSearchResult): DappSearchModel {
return when (searchResult) {
is DappSearchResult.Dapp -> DappSearchModel(
title = searchResult.dapp.name,
description = searchResult.dapp.description,
icon = searchResult.dapp.iconLink,
searchResult = searchResult,
actionIcon = R.drawable.ic_favorite_heart_filled_20.takeIf { searchResult.dapp.isFavourite }
)
is DappSearchResult.Search -> DappSearchModel(
title = searchResult.query,
searchResult = searchResult,
actionIcon = null
)
is DappSearchResult.Url -> DappSearchModel(
title = searchResult.url,
searchResult = searchResult,
actionIcon = null
)
}
}
fun searchResultClicked(searchResult: DappSearchResult) {
launch {
val newUrl = when (searchResult) {
is DappSearchResult.Dapp -> searchResult.dapp.url
is DappSearchResult.Search -> searchResult.searchUrl
is DappSearchResult.Url -> searchResult.url
}
if (!searchResult.isTrustedByNova) {
dAppNotInCatalogWarning.awaitAction(DappUnknownWarningModel(appLinksProvider.email))
}
when (payload.request) {
SearchPayload.Request.GO_TO_URL -> {
dAppSearchResponder.respond(DAppSearchCommunicator.Response.NewUrl(newUrl))
router.finishDappSearch()
}
SearchPayload.Request.OPEN_NEW_URL -> router.openDAppBrowser(DAppBrowserPayload.Address(newUrl))
}
}
}
private fun shouldReportResult() = when (payload.request) {
SearchPayload.Request.GO_TO_URL -> true
SearchPayload.Request.OPEN_NEW_URL -> false
}
fun onCategoryClicked(id: String) {
if (selectedCategoryId.value == id) {
selectedCategoryId.value = null
} else {
selectedCategoryId.value = id
}
}
}
@@ -0,0 +1,130 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.search
import android.view.View
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.domain.isLoaded
import io.novafoundation.nova.common.domain.isLoading
import io.novafoundation.nova.common.domain.onLoaded
import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets
import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets
import io.novafoundation.nova.common.utils.bindTo
import io.novafoundation.nova.common.utils.insets.ImeInsetsState
import io.novafoundation.nova.common.utils.keyboard.hideSoftKeyboard
import io.novafoundation.nova.common.utils.keyboard.showSoftKeyboard
import io.novafoundation.nova.common.view.dialog.warningDialog
import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi
import io.novafoundation.nova.feature_dapp_impl.R
import io.novafoundation.nova.feature_dapp_impl.databinding.FragmentSearchDappBinding
import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureComponent
import io.novafoundation.nova.feature_dapp_impl.domain.search.DappSearchResult
import io.novafoundation.nova.feature_dapp_impl.presentation.main.DappCategoriesAdapter
import javax.inject.Inject
class DappSearchFragment : BaseFragment<DAppSearchViewModel, FragmentSearchDappBinding>(), SearchDappAdapter.Handler, DappCategoriesAdapter.Handler {
companion object {
private const val PAYLOAD = "DappSearchFragment.PAYLOAD"
fun getBundle(payload: SearchPayload) = bundleOf(
PAYLOAD to payload
)
}
override fun createBinding() = FragmentSearchDappBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
private val categoriesAdapter by lazy(LazyThreadSafetyMode.NONE) { DappCategoriesAdapter(imageLoader, this) }
private val adapter by lazy(LazyThreadSafetyMode.NONE) { SearchDappAdapter(this) }
override fun applyInsets(rootView: View) {
binder.searchDappSearch.applyStatusBarInsets()
binder.searchDappSearhContainer.applyNavigationBarInsets(consume = false, imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED)
}
override fun initViews() {
binder.searchDappCategories.adapter = categoriesAdapter
binder.searchDappList.adapter = adapter
binder.searchDappList.setHasFixedSize(true)
binder.searchDappSearch.cancel.setOnClickListener {
viewModel.cancelClicked()
hideKeyboard()
}
binder.searchDappSearch.searchInput.requestFocus()
binder.searchDappSearch.searchInput.content.showSoftKeyboard()
}
override fun inject() {
FeatureUtils.getFeature<DAppFeatureComponent>(this, DAppFeatureApi::class.java)
.dAppSearchComponentFactory()
.create(this, argument(PAYLOAD))
.inject(this)
}
override fun subscribe(viewModel: DAppSearchViewModel) {
setupDAppNotInCatalogWarning()
binder.searchDappSearch.searchInput.content.bindTo(viewModel.query, lifecycleScope)
viewModel.searchResults.observe(::submitListPreservingViewPoint)
viewModel.selectQueryTextEvent.observeEvent {
binder.searchDappSearch.searchInput.content.selectAll()
}
viewModel.categoriesFlow.observe {
binder.searchDappCategoriesShimmering.isVisible = it.isLoading()
binder.searchDappCategories.isVisible = it.isLoaded()
it.onLoaded { categoriesAdapter.submitList(it) }
}
}
override fun itemClicked(searchResult: DappSearchResult) {
hideKeyboard()
viewModel.searchResultClicked(searchResult)
}
private fun hideKeyboard() {
binder.searchDappSearch.searchInput.hideSoftKeyboard()
}
private fun submitListPreservingViewPoint(data: List<Any?>) {
val recyclerViewState = binder.searchDappList.layoutManager!!.onSaveInstanceState()
adapter.submitList(data) {
binder.searchDappList.layoutManager!!.onRestoreInstanceState(recyclerViewState)
}
}
private fun setupDAppNotInCatalogWarning() {
viewModel.dAppNotInCatalogWarning.awaitableActionLiveData.observeEvent { event ->
warningDialog(
context = providedContext,
onPositiveClick = { event.onCancel() },
positiveTextRes = R.string.common_close,
negativeTextRes = R.string.dapp_url_warning_open_anyway,
onNegativeClick = { event.onSuccess(Unit) },
styleRes = R.style.AccentNegativeAlertDialogTheme
) {
setTitle(R.string.dapp_url_warning_title)
setMessage(requireContext().getString(R.string.dapp_url_warning_subtitle, event.payload.supportEmail))
}
}
}
override fun onCategoryClicked(id: String) {
viewModel.onCategoryClicked(id)
}
}
@@ -0,0 +1,5 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.search
class DappUnknownWarningModel(
val supportEmail: String
)
@@ -0,0 +1,127 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.search
import android.view.ViewGroup
import io.novafoundation.nova.common.list.BaseGroupedDiffCallback
import io.novafoundation.nova.common.list.GroupedListAdapter
import io.novafoundation.nova.common.list.GroupedListHolder
import io.novafoundation.nova.common.list.PayloadGenerator
import io.novafoundation.nova.common.list.headers.TextHeader
import io.novafoundation.nova.common.list.resolvePayload
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.feature_dapp_impl.domain.search.DappSearchResult
import io.novafoundation.nova.feature_dapp_api.presentation.view.DAppView
import io.novafoundation.nova.feature_dapp_impl.databinding.ItemDappSearchCategoryBinding
import io.novafoundation.nova.feature_dapp_impl.presentation.search.model.DappSearchModel
class SearchDappAdapter(
private val handler: Handler
) : GroupedListAdapter<TextHeader, DappSearchModel>(DiffCallback) {
interface Handler {
fun itemClicked(searchResult: DappSearchResult)
}
override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder {
return CategoryHolder(ItemDappSearchCategoryBinding.inflate(parent.inflater(), parent, false))
}
override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder {
return SearchHolder(DAppView.createUsingMathParentWidth(parent.context), handler)
}
override fun bindGroup(holder: GroupedListHolder, group: TextHeader) {
(holder as CategoryHolder).bind(group)
}
override fun bindChild(holder: GroupedListHolder, child: DappSearchModel) {
(holder as SearchHolder).bind(child)
}
override fun bindChild(holder: GroupedListHolder, position: Int, child: DappSearchModel, payloads: List<Any>) {
resolvePayload(holder, position, payloads) {
(holder as SearchHolder).rebind(child) {
when (it) {
DappSearchModel::title -> bindTitle(child)
}
}
}
}
}
private object DiffCallback : BaseGroupedDiffCallback<TextHeader, DappSearchModel>(TextHeader::class.java) {
override fun areGroupItemsTheSame(oldItem: TextHeader, newItem: TextHeader): Boolean {
return TextHeader.DIFF_CALLBACK.areItemsTheSame(oldItem, newItem)
}
override fun areGroupContentsTheSame(oldItem: TextHeader, newItem: TextHeader): Boolean {
return TextHeader.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
}
override fun areChildItemsTheSame(oldItem: DappSearchModel, newItem: DappSearchModel): Boolean {
return when {
isSingletonItem(oldItem) && isSingletonItem(newItem) -> true
else -> oldItem.title == newItem.title
}
}
override fun areChildContentsTheSame(oldItem: DappSearchModel, newItem: DappSearchModel): Boolean {
return oldItem.title == newItem.title && oldItem.description == newItem.description && oldItem.icon == newItem.icon
}
override fun getChildChangePayload(oldItem: DappSearchModel, newItem: DappSearchModel): Any? {
return SearchDappPayloadGenerator.diff(oldItem, newItem)
}
private fun isSingletonItem(item: DappSearchModel) = when (item.searchResult) {
is DappSearchResult.Search -> true
is DappSearchResult.Url -> true
is DappSearchResult.Dapp -> false
}
}
private object SearchDappPayloadGenerator : PayloadGenerator<DappSearchModel>(DappSearchModel::title)
private class CategoryHolder(private val binder: ItemDappSearchCategoryBinding) : GroupedListHolder(binder.root) {
fun bind(item: TextHeader) {
binder.searchCategory.text = item.content
}
}
private class SearchHolder(
private val dAppView: DAppView,
private val itemHandler: SearchDappAdapter.Handler
) : GroupedListHolder(dAppView) {
override fun unbind() {
dAppView.clearIcon()
}
fun bind(item: DappSearchModel) = with(dAppView) {
setIconUrl(item.icon)
bindTitle(item)
setSubtitle(item.description)
showSubtitle(item.description != null)
setActionResource(item.actionIcon)
bindClick(item)
}
fun bindTitle(item: DappSearchModel) {
dAppView.setTitle(item.title)
}
fun rebind(item: DappSearchModel, action: SearchHolder.() -> Unit) = with(containerView) {
bindClick(item)
action()
}
private fun bindClick(item: DappSearchModel) = with(dAppView) {
setOnClickListener { itemHandler.itemClicked(item.searchResult) }
}
}
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.search
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class SearchPayload(
val initialUrl: String?,
val request: Request,
val preselectedCategoryId: String? = null
) : Parcelable {
enum class Request {
GO_TO_URL,
OPEN_NEW_URL,
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.search.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_dapp_impl.presentation.search.DappSearchFragment
import io.novafoundation.nova.feature_dapp_impl.presentation.search.SearchPayload
@Subcomponent(
modules = [
DAppSearchModule::class
]
)
@ScreenScope
interface DAppSearchComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: SearchPayload,
): DAppSearchComponent
}
fun inject(fragment: DappSearchFragment)
}
@@ -0,0 +1,63 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.search.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.data.network.AppLinksProvider
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository
import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor
import io.novafoundation.nova.feature_dapp_impl.domain.search.SearchDappInteractor
import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator
import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchViewModel
import io.novafoundation.nova.feature_dapp_impl.presentation.search.SearchPayload
@Module(includes = [ViewModelModule::class])
class DAppSearchModule {
@Provides
@ScreenScope
fun provideInteractor(
dAppMetadataRepository: DAppMetadataRepository,
favouritesDAppRepository: FavouritesDAppRepository
) = SearchDappInteractor(dAppMetadataRepository, favouritesDAppRepository)
@Provides
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): DAppSearchViewModel {
return ViewModelProvider(fragment, factory).get(DAppSearchViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(DAppSearchViewModel::class)
fun provideViewModel(
router: DAppRouter,
resourceManager: ResourceManager,
interactor: SearchDappInteractor,
dappInteractor: DappInteractor,
searchResponder: DAppSearchCommunicator,
payload: SearchPayload,
actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
appLinksProvider: AppLinksProvider
): ViewModel {
return DAppSearchViewModel(
router = router,
resourceManager = resourceManager,
interactor = interactor,
dAppSearchResponder = searchResponder,
payload = payload,
actionAwaitableMixinFactory = actionAwaitableMixinFactory,
appLinksProvider = appLinksProvider,
dappInteractor = dappInteractor
)
}
}
@@ -0,0 +1,12 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.search.model
import androidx.annotation.DrawableRes
import io.novafoundation.nova.feature_dapp_impl.domain.search.DappSearchResult
class DappSearchModel(
val title: String,
val description: String? = null,
val icon: String? = null,
@DrawableRes val actionIcon: Int?,
val searchResult: DappSearchResult
)
@@ -0,0 +1,10 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.tab
import io.novafoundation.nova.common.utils.images.Icon
data class BrowserTabRvItem(
val tabId: String,
val tabName: String?,
val icon: Icon?,
val tabScreenshotPath: String?,
)
@@ -0,0 +1,95 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.tab
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import coil.ImageLoader
import coil.clear
import coil.load
import io.novafoundation.nova.common.list.BaseViewHolder
import io.novafoundation.nova.common.utils.ImageMonitor
import io.novafoundation.nova.common.utils.images.Icon
import io.novafoundation.nova.common.utils.images.setIconOrMakeGone
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.setPathOrStopWatching
import io.novafoundation.nova.feature_dapp_impl.databinding.ItemBrowserTabBinding
import java.io.File
class BrowserTabsAdapter(
private val imageLoader: ImageLoader,
private val handler: Handler
) : ListAdapter<BrowserTabRvItem, BrowserTabViewHolder>(DiffCallback) {
interface Handler {
fun tabClicked(item: BrowserTabRvItem, view: View)
fun tabCloseClicked(item: BrowserTabRvItem)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BrowserTabViewHolder {
return BrowserTabViewHolder(ItemBrowserTabBinding.inflate(parent.inflater(), parent, false), imageLoader, handler)
}
override fun onBindViewHolder(holder: BrowserTabViewHolder, position: Int) {
holder.bind(getItem(position))
}
}
private object DiffCallback : DiffUtil.ItemCallback<BrowserTabRvItem>() {
override fun areItemsTheSame(oldItem: BrowserTabRvItem, newItem: BrowserTabRvItem): Boolean {
return oldItem.tabId == newItem.tabId
}
override fun areContentsTheSame(oldItem: BrowserTabRvItem, newItem: BrowserTabRvItem): Boolean {
return oldItem == newItem
}
}
class BrowserTabViewHolder(
private val binder: ItemBrowserTabBinding,
private val imageLoader: ImageLoader,
private val itemHandler: BrowserTabsAdapter.Handler,
) : BaseViewHolder(binder.root) {
private val screenshotImageMonitor = ImageMonitor(
imageView = binder.browserTabScreenshot,
imageLoader = imageLoader
)
private val tabIconImageMonitor = ImageMonitor(
imageView = binder.browserTabFavicon,
imageLoader = imageLoader
)
fun bind(item: BrowserTabRvItem) = with(binder) {
browserTabCard.setOnClickListener { itemHandler.tabClicked(item, browserTabScreenshot) }
browserTabClose.setOnClickListener { itemHandler.tabCloseClicked(item) }
browserTabSiteName.text = item.tabName
browserTabScreenshot.load(item.tabScreenshotPath?.asFile(), imageLoader)
screenshotImageMonitor.setPathOrStopWatching(item.tabScreenshotPath)
browserTabFavicon.setIconOrMakeGone(item.icon, imageLoader)
if (item.icon is Icon.FromFile) {
tabIconImageMonitor.setPathOrStopWatching(item.icon.data.absolutePath)
} else {
tabIconImageMonitor.stopMonitoring()
}
}
override fun unbind() {
screenshotImageMonitor.stopMonitoring()
tabIconImageMonitor.stopMonitoring()
with(binder) {
browserTabScreenshot.clear()
browserTabFavicon.clear()
}
}
private fun String.asFile() = File(this)
}
@@ -0,0 +1,65 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.tab
import android.view.View
import androidx.navigation.fragment.FragmentNavigatorExtras
import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi
import io.novafoundation.nova.feature_dapp_impl.databinding.FragmentBrowserTabsBinding
import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureComponent
import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.DAPP_SHARED_ELEMENT_ID_IMAGE_TAB
import javax.inject.Inject
class BrowserTabsFragment : BaseFragment<BrowserTabsViewModel, FragmentBrowserTabsBinding>(), BrowserTabsAdapter.Handler {
@Inject
lateinit var imageLoader: ImageLoader
private val adapter by lazy(LazyThreadSafetyMode.NONE) {
BrowserTabsAdapter(imageLoader, this)
}
override fun createBinding() = FragmentBrowserTabsBinding.inflate(layoutInflater)
override fun initViews() {
onBackPressed { viewModel.done() }
binder.browserTabsList.layoutManager = GridLayoutManager(requireContext(), 2)
binder.browserTabsList.adapter = adapter
binder.browserTabsCloseTabs.setOnClickListener { viewModel.closeAllTabs() }
binder.browserTabsAddTab.setOnClickListener { viewModel.addTab() }
binder.browserTabsDone.setOnClickListener { viewModel.done() }
}
override fun inject() {
FeatureUtils.getFeature<DAppFeatureComponent>(this, DAppFeatureApi::class.java)
.browserTabsComponentFactory()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: BrowserTabsViewModel) {
setupCloseAllDappTabsDialogue(viewModel.closeAllTabsConfirmation)
viewModel.tabsFlow.observe {
adapter.submitList(it)
binder.browserTabsList.scrollToPosition(it.size - 1)
}
}
override fun tabClicked(item: BrowserTabRvItem, view: View) {
view.transitionName = DAPP_SHARED_ELEMENT_ID_IMAGE_TAB
val extras = FragmentNavigatorExtras(
view to DAPP_SHARED_ELEMENT_ID_IMAGE_TAB
)
viewModel.openTab(item, extras)
}
override fun tabCloseClicked(item: BrowserTabRvItem) {
viewModel.closeTab(item.tabId)
}
}
@@ -0,0 +1,65 @@
package io.novafoundation.nova.feature_dapp_impl.presentation.tab
import androidx.navigation.fragment.FragmentNavigator
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.actionAwaitable.awaitAction
import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction
import io.novafoundation.nova.common.utils.Urls
import io.novafoundation.nova.common.utils.images.asFileIcon
import io.novafoundation.nova.common.utils.images.asUrlIcon
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter
import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.BrowserTabService
import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTab
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class BrowserTabsViewModel(
private val router: DAppRouter,
private val browserTabService: BrowserTabService,
private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
private val accountUseCase: SelectedAccountUseCase
) : BaseViewModel() {
val closeAllTabsConfirmation = actionAwaitableMixinFactory.confirmingAction<Unit>()
val tabsFlow = browserTabService.tabStateFlow
.map { it.tabs }
.mapList {
mapBrowserTab(it)
}.shareInBackground()
private fun mapBrowserTab(it: BrowserTab) = BrowserTabRvItem(
tabId = it.id,
tabName = it.pageSnapshot.pageName ?: Urls.domainOf(it.currentUrl),
icon = it.knownDAppMetadata?.iconLink?.asUrlIcon() ?: it.pageSnapshot.pageIconPath?.asFileIcon(),
tabScreenshotPath = it.pageSnapshot.pagePicturePath
)
fun openTab(tab: BrowserTabRvItem, extras: FragmentNavigator.Extras) = launch {
router.openDAppBrowser(DAppBrowserPayload.Tab(tab.tabId), extras)
}
fun closeTab(tabId: String) = launch {
browserTabService.removeTab(tabId)
}
fun closeAllTabs() = launch {
closeAllTabsConfirmation.awaitAction()
val metaAccount = accountUseCase.getSelectedMetaAccount()
browserTabService.removeTabsForMetaAccount(metaAccount.id)
router.closeTabsScreen()
}
fun addTab() {
router.openDappSearch()
}
fun done() {
router.closeTabsScreen()
}
}

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