Initial commit: Pezkuwi Wallet Android

Security hardened release:
- Code obfuscation enabled (minifyEnabled=true, shrinkResources=true)
- Sensitive files excluded (google-services.json, keystores)
- Branch.io key moved to BuildConfig placeholder
- Updated dependencies: OkHttp 4.12.0, Gson 2.10.1, BouncyCastle 1.77
- Comprehensive ProGuard rules for crypto wallet
- Navigation 2.7.7, Lifecycle 2.7.0, ConstraintLayout 2.1.4
This commit is contained in:
2026-02-12 05:19:41 +03:00
commit a294aa1a6b
7687 changed files with 441811 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+47
View File
@@ -0,0 +1,47 @@
apply plugin: 'kotlin-parcelize'
apply from: '../tests.gradle'
android {
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
namespace 'io.novafoundation.nova.feature_onboarding_impl'
buildFeatures {
viewBinding true
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':core-db')
implementation project(':common')
implementation project(':feature-onboarding-api')
implementation project(':feature-account-api')
implementation project(':feature-wallet-api')
implementation project(':feature-ledger-core')
implementation project(':feature-versions-api')
implementation project(':feature-cloud-backup-api')
implementation kotlinDep
implementation androidDep
implementation materialDep
implementation constraintDep
implementation coroutinesDep
implementation daggerDep
ksp daggerCompiler
implementation lifecycleDep
ksp lifecycleCompiler
testImplementation jUnitDep
testImplementation mockitoDep
}
@@ -0,0 +1 @@
<manifest />
@@ -0,0 +1,29 @@
package io.novafoundation.nova.feature_onboarding_impl
import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload
import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportAccountPayload
interface OnboardingRouter {
fun openCreateFirstWallet()
fun openMnemonicScreen(accountName: String?, payload: AddAccountPayload)
fun openImportAccountScreen(payload: ImportAccountPayload)
fun openCreateWatchWallet()
fun openStartImportParitySigner()
fun openStartImportLegacyLedger()
fun openStartImportGenericLedger()
fun back()
fun openStartImportPolkadotVault()
fun openImportOptionsScreen()
fun restoreCloudBackup()
}
@@ -0,0 +1,50 @@
package io.novafoundation.nova.feature_onboarding_impl.di
import dagger.BindsInstance
import dagger.Component
import io.novafoundation.nova.common.di.CommonApi
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi
import io.novafoundation.nova.feature_ledger_core.di.LedgerCoreApi
import io.novafoundation.nova.feature_onboarding_api.di.OnboardingFeatureApi
import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter
import io.novafoundation.nova.feature_onboarding_impl.presentation.importChooser.di.ImportWalletOptionsComponent
import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.di.WelcomeComponent
import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi
@Component(
dependencies = [
OnboardingFeatureDependencies::class
],
modules = [
OnboardingFeatureModule::class
]
)
@FeatureScope
interface OnboardingFeatureComponent : OnboardingFeatureApi {
fun welcomeComponentFactory(): WelcomeComponent.Factory
fun importWalletOptionsComponentFactory(): ImportWalletOptionsComponent.Factory
@Component.Factory
interface Factory {
fun create(
@BindsInstance onboardingRouter: OnboardingRouter,
deps: OnboardingFeatureDependencies
): OnboardingFeatureComponent
}
@Component(
dependencies = [
CommonApi::class,
AccountFeatureApi::class,
VersionsFeatureApi::class,
LedgerCoreApi::class,
CloudBackupFeatureApi::class
]
)
interface OnboardingFeatureDependenciesComponent : OnboardingFeatureDependencies
}
@@ -0,0 +1,38 @@
package io.novafoundation.nova.feature_onboarding_impl.di
import io.novafoundation.nova.common.data.network.AppLinksProvider
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.presenatation.mixin.importType.ImportTypeChooserMixin
import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker
import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService
import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory
import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor
interface OnboardingFeatureDependencies {
fun updateNotificationsInteractor(): UpdateNotificationsInteractor
fun accountRepository(): AccountRepository
fun resourceManager(): ResourceManager
fun appLinksProvider(): AppLinksProvider
fun importTypeChooserMixin(): ImportTypeChooserMixin.Presentation
fun progressDialogMixinFactory(): ProgressDialogMixinFactory
fun customDialogProvider(): CustomDialogDisplayer.Presentation
val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory
val ledgerMigrationTracker: LedgerMigrationTracker
val cloudBackupService: CloudBackupService
val cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory
}
@@ -0,0 +1,30 @@
package io.novafoundation.nova.feature_onboarding_impl.di
import io.novafoundation.nova.common.di.FeatureApiHolder
import io.novafoundation.nova.common.di.FeatureContainer
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi
import io.novafoundation.nova.feature_ledger_core.di.LedgerCoreApi
import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter
import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi
import javax.inject.Inject
@ApplicationScope
class OnboardingFeatureHolder @Inject constructor(
featureContainer: FeatureContainer,
private val onboardingRouter: OnboardingRouter
) : FeatureApiHolder(featureContainer) {
override fun initializeDependencies(): Any {
val onboardingFeatureDependencies = DaggerOnboardingFeatureComponent_OnboardingFeatureDependenciesComponent.builder()
.commonApi(commonApi())
.versionsFeatureApi(getFeature(VersionsFeatureApi::class.java))
.accountFeatureApi(getFeature(AccountFeatureApi::class.java))
.cloudBackupFeatureApi(getFeature(CloudBackupFeatureApi::class.java))
.ledgerCoreApi(getFeature(LedgerCoreApi::class.java))
.build()
return DaggerOnboardingFeatureComponent.factory()
.create(onboardingRouter, onboardingFeatureDependencies)
}
}
@@ -0,0 +1,20 @@
package io.novafoundation.nova.feature_onboarding_impl.di
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService
import io.novafoundation.nova.feature_onboarding_api.domain.OnboardingInteractor
import io.novafoundation.nova.feature_onboarding_impl.domain.OnboardingInteractorImpl
@Module
class OnboardingFeatureModule {
@Provides
fun provideOnboardingInteractor(
cloudBackupService: CloudBackupService,
accountRepository: AccountRepository
): OnboardingInteractor {
return OnboardingInteractorImpl(cloudBackupService, accountRepository)
}
}
@@ -0,0 +1,24 @@
package io.novafoundation.nova.feature_onboarding_impl.domain
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService
import io.novafoundation.nova.feature_onboarding_api.domain.OnboardingInteractor
class OnboardingInteractorImpl(
private val cloudBackupService: CloudBackupService,
private val accountRepository: AccountRepository
) : OnboardingInteractor {
override suspend fun checkCloudBackupIsExist(): Result<Boolean> {
return cloudBackupService.isCloudBackupExist()
}
override suspend fun isCloudBackupAvailableForImport(): Boolean {
return !cloudBackupService.session.isSyncWithCloudEnabled() &&
!accountRepository.hasActiveMetaAccounts()
}
override suspend fun signInToCloud(): Result<Unit> {
return cloudBackupService.signInToCloud()
}
}
@@ -0,0 +1,51 @@
package io.novafoundation.nova.feature_onboarding_impl.presentation.importChooser
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.mixin.impl.setupCustomDialogDisplayer
import io.novafoundation.nova.common.utils.progress.observeProgressDialog
import io.novafoundation.nova.common.utils.setVisible
import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.observeConfirmationAction
import io.novafoundation.nova.feature_onboarding_api.di.OnboardingFeatureApi
import io.novafoundation.nova.feature_onboarding_impl.databinding.FragmentImportWalletOptionsBinding
import io.novafoundation.nova.feature_onboarding_impl.di.OnboardingFeatureComponent
import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.SelectHardwareWalletBottomSheet
class ImportWalletOptionsFragment : BaseFragment<ImportWalletOptionsViewModel, FragmentImportWalletOptionsBinding>() {
override fun createBinding() = FragmentImportWalletOptionsBinding.inflate(layoutInflater)
override fun initViews() {
binder.importOptionsToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.importOptionPassphrase.setOnClickListener { viewModel.importMnemonicClicked() }
binder.importOptionTrustWallet.setOnClickListener { viewModel.importTrustWalletClicked() }
binder.importOptionCloud.setOnClickListener { viewModel.importCloudClicked() }
binder.importOptionHardware.setOnClickListener { viewModel.importHardwareClicked() }
binder.importOptionWatchOnly.setOnClickListener { viewModel.importWatchOnlyClicked() }
binder.importOptionRawSeed.setOnClickListener { viewModel.importRawSeedClicked() }
binder.importOptionJson.setOnClickListener { viewModel.importJsonClicked() }
}
override fun inject() {
FeatureUtils.getFeature<OnboardingFeatureComponent>(context!!, OnboardingFeatureApi::class.java)
.importWalletOptionsComponentFactory()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: ImportWalletOptionsViewModel) {
setupCustomDialogDisplayer(viewModel)
observeProgressDialog(viewModel.progressDialogMixin)
observeConfirmationAction(viewModel.cloudBackupChangingWarningMixin)
viewModel.selectHardwareWallet.awaitableActionLiveData.observeEvent {
SelectHardwareWalletBottomSheet(requireContext(), it.payload, it.onSuccess)
.show()
}
viewModel.showImportViaCloudButton.observe {
binder.importOptionCloud.setVisible(it)
}
}
}
@@ -0,0 +1,126 @@
package io.novafoundation.nova.feature_onboarding_impl.presentation.importChooser
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer
import io.novafoundation.nova.common.mixin.api.displayDialogOrNothing
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory
import io.novafoundation.nova.common.utils.progress.startProgress
import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant
import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload
import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportAccountPayload
import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportType
import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportType.Mnemonic.Origin
import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapCheckBackupAvailableFailureToUi
import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory
import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker
import io.novafoundation.nova.feature_onboarding_api.domain.OnboardingInteractor
import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter
import io.novafoundation.nova.feature_onboarding_impl.R
import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.SelectHardwareWalletBottomSheet
import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.model.HardwareWalletModel
import kotlinx.coroutines.launch
class ImportWalletOptionsViewModel(
private val resourceManager: ResourceManager,
private val router: OnboardingRouter,
private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
private val onboardingInteractor: OnboardingInteractor,
private val progressDialogMixinFactory: ProgressDialogMixinFactory,
customDialogProvider: CustomDialogDisplayer.Presentation,
cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory,
private val ledgerMigrationTracker: LedgerMigrationTracker,
) : BaseViewModel(), CustomDialogDisplayer.Presentation by customDialogProvider {
val progressDialogMixin = progressDialogMixinFactory.create()
val cloudBackupChangingWarningMixin = cloudBackupChangingWarningMixinFactory.create(this)
val selectHardwareWallet = actionAwaitableMixinFactory.create<SelectHardwareWalletBottomSheet.Payload, HardwareWalletModel>()
val showImportViaCloudButton = flowOf { onboardingInteractor.isCloudBackupAvailableForImport() }
.shareInBackground()
fun backClicked() {
router.back()
}
fun importMnemonicClicked() {
openImportType(ImportType.Mnemonic())
}
fun importTrustWalletClicked() {
openImportType(ImportType.Mnemonic(origin = Origin.TRUST_WALLET))
}
fun importCloudClicked() = launch {
progressDialogMixin.startProgress(R.string.loocking_backup_progress) {
onboardingInteractor.checkCloudBackupIsExist()
.onSuccess { isCloudBackupExist ->
if (isCloudBackupExist) {
router.restoreCloudBackup()
} else {
showBackupNotFoundError()
}
}.onFailure {
val payload = mapCheckBackupAvailableFailureToUi(resourceManager, it, ::initSignIn)
displayDialogOrNothing(payload)
}
}
}
fun importHardwareClicked() {
cloudBackupChangingWarningMixin.launchChangingConfirmationIfNeeded {
launch {
val genericLedgerSupported = ledgerMigrationTracker.anyChainSupportsMigrationApp()
val payload = SelectHardwareWalletBottomSheet.Payload(genericLedgerSupported)
when (val selection = selectHardwareWallet.awaitAction(payload)) {
HardwareWalletModel.LedgerLegacy -> router.openStartImportLegacyLedger()
HardwareWalletModel.LedgerGeneric -> router.openStartImportGenericLedger()
is HardwareWalletModel.PolkadotVault -> when (selection.variant) {
PolkadotVaultVariant.POLKADOT_VAULT -> router.openStartImportPolkadotVault()
PolkadotVaultVariant.PARITY_SIGNER -> router.openStartImportParitySigner()
}
}
}
}
}
fun importWatchOnlyClicked() {
cloudBackupChangingWarningMixin.launchChangingConfirmationIfNeeded {
router.openCreateWatchWallet()
}
}
fun importRawSeedClicked() {
openImportType(ImportType.Seed)
}
fun importJsonClicked() {
openImportType(ImportType.Json)
}
private fun openImportType(importType: ImportType) {
cloudBackupChangingWarningMixin.launchChangingConfirmationIfNeeded {
router.openImportAccountScreen(ImportAccountPayload(importType = importType, addAccountPayload = AddAccountPayload.MetaAccount))
}
}
private fun initSignIn() {
launch {
onboardingInteractor.signInToCloud()
}
}
private fun showBackupNotFoundError() {
showError(
resourceManager.getString(R.string.import_wallet_cloud_backup_not_found_title),
resourceManager.getString(R.string.import_wallet_cloud_backup_not_found_subtitle),
)
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_onboarding_impl.presentation.importChooser.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_onboarding_impl.presentation.importChooser.ImportWalletOptionsFragment
@Subcomponent(
modules = [
ImportWalletOptionsModule::class
]
)
@ScreenScope
interface ImportWalletOptionsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
): ImportWalletOptionsComponent
}
fun inject(fragment: ImportWalletOptionsFragment)
}
@@ -0,0 +1,56 @@
package io.novafoundation.nova.feature_onboarding_impl.presentation.importChooser.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory
import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory
import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker
import io.novafoundation.nova.feature_onboarding_api.domain.OnboardingInteractor
import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter
import io.novafoundation.nova.feature_onboarding_impl.presentation.importChooser.ImportWalletOptionsViewModel
@Module(includes = [ViewModelModule::class])
class ImportWalletOptionsModule {
@Provides
@IntoMap
@ViewModelKey(ImportWalletOptionsViewModel::class)
fun provideViewModel(
resourceManager: ResourceManager,
router: OnboardingRouter,
actionAwaitableMixin: ActionAwaitableMixin.Factory,
progressDialogMixinFactory: ProgressDialogMixinFactory,
onboardingInteractor: OnboardingInteractor,
customDialogProvider: CustomDialogDisplayer.Presentation,
cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory,
ledgerMigrationTracker: LedgerMigrationTracker
): ViewModel {
return ImportWalletOptionsViewModel(
resourceManager,
router,
actionAwaitableMixin,
onboardingInteractor,
progressDialogMixinFactory,
customDialogProvider,
cloudBackupChangingWarningMixinFactory,
ledgerMigrationTracker
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): ImportWalletOptionsViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(ImportWalletOptionsViewModel::class.java)
}
}
@@ -0,0 +1,61 @@
package io.novafoundation.nova.feature_onboarding_impl.presentation.view
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import com.google.android.material.card.MaterialCardView
import io.novafoundation.nova.common.utils.WithContextExtensions
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.useAttributes
import io.novafoundation.nova.feature_onboarding_impl.R
import io.novafoundation.nova.feature_onboarding_impl.databinding.ViewImportOptionBinding
class ImportOptionView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
) : MaterialCardView(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) {
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 2.dpF // It will be drawn along border with clipping and finally will be viewed as 1dp
color = context.getColor(R.color.container_border)
}
private val binder = ViewImportOptionBinding.inflate(inflater(), this)
init {
setCardBackgroundColor(context.getColor(R.color.button_background_secondary))
radius = 12.dpF
cardElevation = 0.dpF
elevation = 0.dpF
attrs?.let(::applyAttributes)
}
override fun onDrawForeground(canvas: Canvas) {
super.onDrawForeground(canvas)
canvas.save()
canvas.clipRect(binder.importOptionImage.left, binder.importOptionImage.top, binder.importOptionImage.right, binder.importOptionImage.bottom)
canvas.drawRoundRect(
binder.importOptionImage.left.toFloat(),
binder.importOptionImage.top.toFloat(),
binder.importOptionImage.right.toFloat(),
binder.importOptionImage.bottom.toFloat() + radius,
radius,
radius,
strokePaint
)
canvas.restore()
}
private fun applyAttributes(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.ImportOptionView) {
if (it.hasValue(R.styleable.ImportOptionView_android_src)) {
binder.importOptionImage.setImageDrawable(it.getDrawable(R.styleable.ImportOptionView_android_src))
}
binder.importOptionName.text = it.getString(R.styleable.ImportOptionView_title)
binder.importOptionDescription.text = it.getString(R.styleable.ImportOptionView_android_text)
}
}
@@ -0,0 +1,81 @@
package io.novafoundation.nova.feature_onboarding_impl.presentation.welcome
import android.content.Context
import android.os.Bundle
import io.novafoundation.nova.common.databinding.BottomSheeetFixedListBinding
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet
import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textItem
import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textWithDescriptionItem
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant
import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfigProvider
import io.novafoundation.nova.feature_onboarding_impl.R
import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.model.HardwareWalletModel
class SelectHardwareWalletBottomSheet(
context: Context,
private val payload: Payload,
private val onSuccess: (HardwareWalletModel) -> Unit
) : FixedListBottomSheet<BottomSheeetFixedListBinding>(context, viewConfiguration = ViewConfiguration.default(context)) {
class Payload(
val genericLedgerSupported: Boolean
)
private val polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider
init {
polkadotVaultVariantConfigProvider = FeatureUtils
.getFeature<AccountFeatureApi>(context, AccountFeatureApi::class.java)
.polkadotVaultVariantConfigProvider
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.account_select_hardware_wallet)
polkadotVaultItem(PolkadotVaultVariant.POLKADOT_VAULT)
if (payload.genericLedgerSupported) {
textWithDescriptionItem(
iconRes = R.drawable.ic_ledger,
titleRes = R.string.account_ledger_generic_item_title,
descriptionRes = R.string.account_ledger_generic_item_subtitle,
showArrowWhenEnabled = true,
onClick = { onSuccess(HardwareWalletModel.LedgerGeneric) }
)
textItem(
iconRes = R.drawable.ic_ledger_legacy,
titleRes = R.string.account_ledger_nano_x_legacy,
showArrow = true,
applyIconTint = false,
onClick = { onSuccess(HardwareWalletModel.LedgerLegacy) }
)
} else {
textItem(
iconRes = R.drawable.ic_ledger,
titleRes = R.string.account_ledger_nano_x,
showArrow = true,
applyIconTint = false,
onClick = { onSuccess(HardwareWalletModel.LedgerLegacy) }
)
}
polkadotVaultItem(PolkadotVaultVariant.PARITY_SIGNER)
}
private fun polkadotVaultItem(variant: PolkadotVaultVariant) {
val config = polkadotVaultVariantConfigProvider.variantConfigFor(variant)
textItem(
iconRes = config.common.iconRes,
titleRes = config.common.nameRes,
showArrow = true,
applyIconTint = false,
onClick = { onSuccess(HardwareWalletModel.PolkadotVault(variant)) }
)
}
}
@@ -0,0 +1,94 @@
package io.novafoundation.nova.feature_onboarding_impl.presentation.welcome
import android.graphics.Color
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.View
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents
import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets
import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets
import io.novafoundation.nova.common.utils.clickableSpan
import io.novafoundation.nova.common.utils.colorSpan
import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter
import io.novafoundation.nova.common.utils.setFullSpan
import io.novafoundation.nova.common.utils.setVisible
import io.novafoundation.nova.common.utils.toSpannable
import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload
import io.novafoundation.nova.feature_onboarding_api.di.OnboardingFeatureApi
import io.novafoundation.nova.feature_onboarding_impl.R
import io.novafoundation.nova.feature_onboarding_impl.databinding.FragmentWelcomeBinding
import io.novafoundation.nova.feature_onboarding_impl.di.OnboardingFeatureComponent
class WelcomeFragment : BaseFragment<WelcomeViewModel, FragmentWelcomeBinding>() {
companion object {
private const val KEY_DISPLAY_BACK = "display_back"
private const val KEY_ADD_ACCOUNT_PAYLOAD = "add_account_payload"
fun bundle(displayBack: Boolean): Bundle {
return Bundle().apply {
putBoolean(KEY_DISPLAY_BACK, displayBack)
putParcelable(KEY_ADD_ACCOUNT_PAYLOAD, AddAccountPayload.MetaAccount)
}
}
fun bundle(payload: AddAccountPayload): Bundle {
return Bundle().apply {
putBoolean(KEY_DISPLAY_BACK, true)
putParcelable(KEY_ADD_ACCOUNT_PAYLOAD, payload)
}
}
}
override fun createBinding() = FragmentWelcomeBinding.inflate(layoutInflater)
override fun applyInsets(rootView: View) {
binder.welcomeStatus.applyStatusBarInsets()
binder.welcomeTerms.applyNavigationBarInsets()
}
override fun initViews() {
configureTermsAndPrivacy(
getString(R.string.onboarding_terms_and_conditions_1_v2_2_1),
getString(R.string.onboarding_terms_and_conditions_2),
getString(R.string.onboarding_privacy_policy)
)
binder.welcomeTerms.movementMethod = LinkMovementMethod.getInstance()
binder.welcomeTerms.highlightColor = Color.TRANSPARENT
binder.welcomeCreateWalletButton.setOnClickListener { viewModel.createAccountClicked() }
binder.welcomeRestoreWalletButton.setOnClickListener { viewModel.importAccountClicked() }
binder.welcomeBackButton.setOnClickListener { viewModel.backClicked() }
}
private fun configureTermsAndPrivacy(sourceText: String, terms: String, privacy: String) {
val clickableColor = requireContext().getColor(R.color.text_primary)
binder.welcomeTerms.text = SpannableFormatter.format(
sourceText,
terms.toSpannable(colorSpan(clickableColor)).setFullSpan(clickableSpan(viewModel::termsClicked)),
privacy.toSpannable(colorSpan(clickableColor)).setFullSpan(clickableSpan(viewModel::privacyClicked)),
)
}
override fun inject() {
FeatureUtils.getFeature<OnboardingFeatureComponent>(context!!, OnboardingFeatureApi::class.java)
.welcomeComponentFactory()
.create(
fragment = this,
shouldShowBack = argument(KEY_DISPLAY_BACK),
addAccountPayload = argument(KEY_ADD_ACCOUNT_PAYLOAD)
)
.inject(this)
}
override fun subscribe(viewModel: WelcomeViewModel) {
observeBrowserEvents(viewModel)
viewModel.shouldShowBackLiveData.observe(binder.welcomeBackButton::setVisible)
}
}
@@ -0,0 +1,52 @@
package io.novafoundation.nova.feature_onboarding_impl.presentation.welcome
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.mixin.api.Browserable
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload
import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter
import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor
class WelcomeViewModel(
shouldShowBack: Boolean,
private val router: OnboardingRouter,
private val appLinksProvider: AppLinksProvider,
private val addAccountPayload: AddAccountPayload,
updateNotificationsInteractor: UpdateNotificationsInteractor
) : BaseViewModel(),
Browserable {
val shouldShowBackLiveData: LiveData<Boolean> = MutableLiveData(shouldShowBack)
override val openBrowserEvent = MutableLiveData<Event<String>>()
init {
updateNotificationsInteractor.allowInAppUpdateCheck()
}
fun createAccountClicked() {
when (addAccountPayload) {
is AddAccountPayload.MetaAccount -> router.openCreateFirstWallet()
is AddAccountPayload.ChainAccount -> router.openMnemonicScreen(accountName = null, addAccountPayload)
}
}
fun importAccountClicked() {
router.openImportOptionsScreen()
}
fun termsClicked() {
openBrowserEvent.value = Event(appLinksProvider.termsUrl)
}
fun privacyClicked() {
openBrowserEvent.value = Event(appLinksProvider.privacyUrl)
}
fun backClicked() {
router.back()
}
}
@@ -0,0 +1,29 @@
package io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.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_account_api.presenatation.account.add.AddAccountPayload
import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.WelcomeFragment
@Subcomponent(
modules = [
WelcomeModule::class
]
)
@ScreenScope
interface WelcomeComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance shouldShowBack: Boolean,
@BindsInstance addAccountPayload: AddAccountPayload,
): WelcomeComponent
}
fun inject(welcomeFragment: WelcomeFragment)
}
@@ -0,0 +1,48 @@
package io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.data.network.AppLinksProvider
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload
import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker
import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter
import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.WelcomeViewModel
import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor
@Module(includes = [ViewModelModule::class])
class WelcomeModule {
@Provides
@IntoMap
@ViewModelKey(WelcomeViewModel::class)
fun provideViewModel(
router: OnboardingRouter,
appLinksProvider: AppLinksProvider,
shouldShowBack: Boolean,
addAccountPayload: AddAccountPayload,
updateNotificationsInteractor: UpdateNotificationsInteractor,
ledgerMigrationTracker: LedgerMigrationTracker,
): ViewModel {
return WelcomeViewModel(
shouldShowBack,
router,
appLinksProvider,
addAccountPayload,
updateNotificationsInteractor
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): WelcomeViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(WelcomeViewModel::class.java)
}
}
@@ -0,0 +1,12 @@
package io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.model
import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant
sealed class HardwareWalletModel {
class PolkadotVault(val variant: PolkadotVaultVariant) : HardwareWalletModel()
object LedgerGeneric : HardwareWalletModel()
object LedgerLegacy : HardwareWalletModel()
}
@@ -0,0 +1,158 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:background="@color/secondary_screen_background">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/importOptionsToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingBottom="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/importOptionsToolbar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
style="@style/TextAppearance.NovaFoundation.Bold.Title3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
android:text="@string/import_wallet_options_title" />
<GridLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10dp"
android:layout_marginTop="14dp"
android:columnCount="2">
<io.novafoundation.nova.feature_onboarding_impl.presentation.view.ImportOptionView
android:id="@+id/importOptionCloud"
android:layout_width="0dp"
android:layout_columnSpan="2"
android:layout_columnWeight="1"
android:layout_margin="6dp"
android:src="@drawable/ic_import_option_cloud"
android:text="@string/import_wallet_options_cloud_backup_subtitle"
app:title="@string/common_cloud_backup" />
<io.novafoundation.nova.feature_onboarding_impl.presentation.view.ImportOptionView
android:id="@+id/import_option_passphrase"
android:layout_width="0dp"
android:layout_columnSpan="1"
android:layout_columnWeight="1"
android:layout_gravity="fill"
android:layout_margin="6dp"
android:src="@drawable/ic_import_option_passphrase"
android:text="@string/import_wallet_options_mnemonic_subtitle"
app:title="@string/common_passphrase" />
<io.novafoundation.nova.feature_onboarding_impl.presentation.view.ImportOptionView
android:id="@+id/importOptionHardware"
android:layout_width="0dp"
android:layout_columnWeight="1"
android:layout_gravity="fill"
android:layout_margin="6dp"
android:src="@drawable/ic_import_option_hardware"
android:text="@string/account_welcome_hardware_wallet_subtitle"
app:title="@string/account_welcome_hardware_wallet_title" />
<io.novafoundation.nova.feature_onboarding_impl.presentation.view.ImportOptionView
android:id="@+id/importOptionTrustWallet"
android:layout_width="0dp"
android:layout_columnWeight="1"
android:layout_gravity="fill"
android:layout_margin="6dp"
android:src="@drawable/ic_import_option_trust_wallet"
android:text="@string/account_welcome_trust_wallet_subtitle"
app:title="@string/account_welcome_trust_wallet_title" />
<io.novafoundation.nova.feature_onboarding_impl.presentation.view.ImportOptionView
android:id="@+id/importOptionWatchOnly"
android:layout_width="0dp"
android:layout_columnWeight="1"
android:layout_gravity="fill"
android:layout_margin="6dp"
android:src="@drawable/ic_import_option_watch_only"
android:text="@string/account_add_watch_only_description"
app:title="@string/import_wallet_options_watch_only_title" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/importOptionRawSeed"
android:layout_width="0dp"
android:layout_height="52dp"
android:layout_columnWeight="1"
android:layout_gravity="fill"
android:layout_margin="6dp"
android:elevation="0dp"
app:cardBackgroundColor="@color/button_background_secondary"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
style="@style/TextAppearance.NovaFoundation.SemiBold.SubHeadline"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:drawablePadding="12dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:lines="1"
android:paddingStart="12dp"
android:paddingEnd="16dp"
android:text="@string/recovery_raw_seed"
app:drawableStartCompat="@drawable/ic_raw_seed" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/importOptionJson"
android:layout_width="0dp"
android:layout_height="52dp"
android:layout_columnWeight="1"
android:layout_gravity="fill"
android:layout_margin="6dp"
android:elevation="0dp"
app:cardBackgroundColor="@color/button_background_secondary"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
style="@style/TextAppearance.NovaFoundation.SemiBold.SubHeadline"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:drawablePadding="12dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:lines="1"
android:paddingStart="12dp"
android:paddingEnd="16dp"
android:text="@string/recovery_json"
app:drawableStartCompat="@drawable/ic_file_outline" />
</com.google.android.material.card.MaterialCardView>
</GridLayout>
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/welcomeStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/logoImg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="@drawable/ic_create_wallet_background"
app:layout_constraintBottom_toTopOf="@+id/welcomeCreateWalletButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/welcomeBackButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/bg_primary_list_item"
android:padding="@dimen/x2"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/welcomeStatus"
app:srcCompat="@drawable/ic_arrow_back"
app:tint="@color/icon_primary"
tools:visibility="visible" />
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/welcomeCreateWalletButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:layout_marginBottom="10dp"
android:text="@string/onboarding_create_wallet"
app:appearance="primary"
app:layout_constraintBottom_toTopOf="@+id/welcomeRestoreWalletButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/welcomeRestoreWalletButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/onboarding_restore_wallet"
app:appearance="secondary"
app:icon="@drawable/ic_watch_only_filled"
app:layout_constraintBottom_toTopOf="@+id/welcomeTerms"
app:layout_constraintEnd_toEndOf="@+id/welcomeCreateWalletButton"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/welcomeCreateWalletButton"
app:subTitle="@string/account_add_watch_only_description" />
<TextView
android:id="@+id/welcomeTerms"
style="@style/TextAppearance.NovaFoundation.Body1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/x2"
android:layout_marginEnd="@dimen/x2"
android:layout_marginBottom="24dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textColorLink="@color/text_primary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="@string/onboarding_terms_and_conditions_1_v2_2_1" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:parentTag="com.google.android.material.card.MaterialCardView">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/importOptionImage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
android:adjustViewBounds="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_import_option_watch_only" />
<TextView
android:id="@+id/importOptionName"
style="@style/TextAppearance.NovaFoundation.SemiBold.SubHeadline"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="16dp"
android:textColor="@color/text_primary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/importOptionImage"
tools:text="Hardware wallet" />
<TextView
android:id="@+id/importOptionDescription"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
android:textColor="@color/text_secondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/importOptionName"
tools:text="Polkadot Vault, Parity Signer or Ledger" />
</androidx.constraintlayout.widget.ConstraintLayout>
</merge>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="ImportOptionView">
<attr name="android:src" />
<attr name="title" />
<attr name="android:text" />
</declare-styleable>
</resources>
@@ -0,0 +1,16 @@
package io.novafoundation.nova.feature_onboarding_impl
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}