From 5fea60f2a213e5e3083fac59de30b8a6e4d32f0d Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 17 Oct 2022 20:02:36 +0500 Subject: [PATCH] Updated on 2026-08-14 --- app/build.gradle | 27 +- .../common/compose/ComposeDialogManager.kt | 24 +- .../common/compose/OutlinedTextFieldWidget.kt | 27 +- .../tap/common/compose/PinCodeWidget.kt | 2 +- .../com/tangem/tap/common/compose/Warning.kt | 4 +- .../tangem/tap/common/extensions/Context.kt | 6 + .../com/tangem/tap/common/extensions/Store.kt | 88 +++-- .../tap/common/extensions/WalletManager.kt | 9 + .../moduleMessage/ModuleMessageConverter.kt | 24 -- .../domain/DomainErrorConverters.kt | 48 --- .../domain/DomainMessageConverter.kt | 20 - .../com/tangem/tap/common/redux/AppState.kt | 2 + .../tangem/tap/common/redux/LogMiddleware.kt | 1 + .../tap/common/redux/global/GlobalAction.kt | 7 +- .../tap/common/redux/global/GlobalReducer.kt | 11 +- .../IndeterminateProgressButtonWidget.kt | 21 +- .../com/tangem/tap/domain/TangemSdkManager.kt | 5 + .../configurable/config/ConfigManager.kt | 4 + .../domain/configurable/config/JsonModels.kt | 36 +- .../com/tangem/tap/domain/extensions/Card.kt | 2 +- .../domain/extensions/WalletManagerFactory.kt | 43 ++- .../OnboardingWalletStateConverter.kt | 88 +++++ .../domain/tasks/product/ScanProductTask.kt | 20 +- .../tap/domain/tokens/UserTokensRepository.kt | 21 +- .../details/redux/DetailsMiddleware.kt | 11 +- .../features/details/redux/DetailsReducer.kt | 4 +- .../ui/cardsettings/CardSettingsViewModel.kt | 6 +- .../tangem/tap/features/home/HomeFragment.kt | 8 +- .../features/home/compose/StoriesScreen.kt | 28 +- .../home/compose/views/HomeButtons.kt | 78 +++- .../tap/features/home/redux/HomeAction.kt | 2 +- .../tap/features/home/redux/HomeMiddleware.kt | 205 +++++++---- .../tap/features/home/redux/HomeReducer.kt | 3 + .../tap/features/home/redux/HomeState.kt | 8 +- .../features/onboarding/OnboardingHelper.kt | 10 +- .../onboarding/OnboardingSaltPayHelper.kt | 34 ++ .../products/note/OnboardingNoteFragment.kt | 6 +- .../OnboardingOtherCardsFragment.kt | 4 +- .../products/twins/ui/TwinsCardsFragment.kt | 8 +- .../wallet/redux/OnboardingWalletAction.kt | 14 +- .../redux/OnboardingWalletMiddleware.kt | 152 +++++--- .../wallet/redux/OnboardingWalletReducer.kt | 101 ++--- .../wallet/redux/OnboardingWalletState.kt | 34 +- .../wallet/saltPay/GnosisRegistrator.kt | 164 +++++++++ .../products/wallet/saltPay/PinFilters.kt | 18 + .../products/wallet/saltPay/SaltPayConfig.kt | 27 ++ .../saltPay/SaltPayRegistrationManager.kt | 156 ++++++++ .../wallet/saltPay/SaltPayRegistrationTask.kt | 170 +++++++++ .../saltPay/redux/OnboardingSaltPayAction.kt | 34 ++ .../redux/OnboardingSaltPayMiddleware.kt | 288 +++++++++++++++ .../saltPay/redux/OnboardingSaltPayReducer.kt | 86 +++++ .../saltPay/redux/OnboardingSaltPayState.kt | 102 ++++++ .../wallet/ui/OnboardingWalletFragment.kt | 305 ++++++++++++--- .../dialogs/ConfirmDiscardingBackupDialog.kt | 2 +- .../ui/dialogs/UnfinishedBackupFoundDialog.kt | 2 +- .../service/OnboardingWalletService.kt | 13 - .../compose/AddCustomTokenScreen.kt | 47 ++- .../middlewares/MultiWalletMiddleware.kt | 2 +- .../tap/features/wallet/ui/WalletFragment.kt | 3 +- .../tap/persistence/PreferencesStorage.kt | 2 + .../SaltPayRegistrationPrefStorage.kt | 82 +++++ .../res/drawable/ic_connecting_socket.xml | 12 + app/src/main/res/drawable/ic_in_progress.xml | 22 ++ app/src/main/res/drawable/ic_passport.xml | 26 ++ app/src/main/res/drawable/ic_tangem.xml | 20 +- .../main/res/drawable/img_salt_pay_visa.png | Bin 0 -> 60037 bytes .../res/layout/fragment_onboarding_wallet.xml | 346 +++++++++--------- .../layout_onboarding_buttons_add_cards.xml | 19 +- .../layout_onboarding_buttons_common.xml | 5 +- .../layout_onboarding_container_top.xml | 2 +- .../res/layout/layout_onboarding_home_top.xml | 2 +- .../res/layout/layout_onboarding_saltpay.xml | 35 ++ ...layout_onboarding_saltpay_connect_card.xml | 78 ++++ .../layout_onboarding_saltpay_pin_code.xml | 72 ++++ ...out_onboarding_saltpay_verify_identity.xml | 76 ++++ ...ng_saltpay_verify_identity_in_progress.xml | 83 +++++ .../layout/lp_onboarding_create_wallet.xml | 2 +- .../main/res/layout/lp_onboarding_done.xml | 2 +- .../layout/lp_onboarding_done_activation.xml | 2 +- .../lp_onboarding_done_activation_twins.xml | 2 +- .../res/layout/lp_onboarding_topup_wallet.xml | 2 +- .../lp_onboarding_topup_wallet_twins.xml | 2 +- dependencies.gradle | 7 +- 83 files changed, 2801 insertions(+), 775 deletions(-) delete mode 100644 app/src/main/java/com/tangem/tap/common/moduleMessage/ModuleMessageConverter.kt delete mode 100644 app/src/main/java/com/tangem/tap/common/moduleMessage/domain/DomainErrorConverters.kt delete mode 100644 app/src/main/java/com/tangem/tap/common/moduleMessage/domain/DomainMessageConverter.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/statePrinter/OnboardingWalletStateConverter.kt create mode 100644 app/src/main/java/com/tangem/tap/features/onboarding/OnboardingSaltPayHelper.kt create mode 100644 app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/GnosisRegistrator.kt create mode 100644 app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/PinFilters.kt create mode 100644 app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/SaltPayConfig.kt create mode 100644 app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/SaltPayRegistrationManager.kt create mode 100644 app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/SaltPayRegistrationTask.kt create mode 100644 app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayAction.kt create mode 100644 app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayMiddleware.kt create mode 100644 app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayReducer.kt create mode 100644 app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayState.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/onboarding/service/OnboardingWalletService.kt create mode 100644 app/src/main/java/com/tangem/tap/persistence/SaltPayRegistrationPrefStorage.kt create mode 100644 app/src/main/res/drawable/ic_connecting_socket.xml create mode 100644 app/src/main/res/drawable/ic_in_progress.xml create mode 100644 app/src/main/res/drawable/ic_passport.xml create mode 100644 app/src/main/res/drawable/img_salt_pay_visa.png create mode 100644 app/src/main/res/layout/layout_onboarding_saltpay.xml create mode 100644 app/src/main/res/layout/layout_onboarding_saltpay_connect_card.xml create mode 100644 app/src/main/res/layout/layout_onboarding_saltpay_pin_code.xml create mode 100644 app/src/main/res/layout/layout_onboarding_saltpay_verify_identity.xml create mode 100644 app/src/main/res/layout/layout_onboarding_saltpay_verify_identity_in_progress.xml diff --git a/app/build.gradle b/app/build.gradle index c37a4da509..f1fa3b08ef 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,13 +6,13 @@ apply plugin: 'com.google.firebase.crashlytics' apply from: '../dependencies.gradle' android { - compileSdkVersion 32 + compileSdkVersion 33 defaultConfig { applicationId "com.tangem.wallet" versionCode project.hasProperty('versionCode') ? project.property('versionCode') as int : 1 versionName project.hasProperty('versionName') ? project.property('versionName') : "1.0.0.-SNAPSHOT" minSdkVersion 21 - targetSdkVersion 32 + targetSdkVersion 33 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" buildConfigField 'Boolean', environmentConfig.testActionEnabled, 'false' @@ -83,11 +83,11 @@ dependencies { implementation implementation(project(path: ':network')) implementation implementation(project(path: ':common')) - implementation 'androidx.core:core-ktx:1.8.0' - implementation 'androidx.appcompat:appcompat:1.4.1' - implementation 'androidx.fragment:fragment-ktx:1.4.1' - implementation 'androidx.constraintlayout:constraintlayout:2.1.3' - implementation 'com.google.android.material:material:1.5.0' + implementation 'androidx.core:core-ktx:1.9.0' + implementation 'androidx.appcompat:appcompat:1.5.1' + implementation 'androidx.fragment:fragment-ktx:1.5.3' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'com.google.android.material:material:1.6.1' implementation 'com.google.android.play:core:1.10.3' implementation 'com.google.android.play:core-ktx:1.8.1' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' @@ -97,15 +97,15 @@ dependencies { implementation "com.tangem.tangem-sdk-kotlin:android:${versions.tangem_card_sdk}" // WebView - implementation "androidx.browser:browser:1.3.0" + implementation "androidx.browser:browser:1.4.0" implementation 'com.jakewharton.timber:timber:4.7.1' //lifecycle - implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1" - implementation "androidx.lifecycle:lifecycle-common-java8:2.3.1" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.1" + implementation "androidx.lifecycle:lifecycle-common-java8:2.5.1" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.5.1" implementation "org.rekotlin:rekotlin:1.0.4" @@ -176,6 +176,9 @@ dependencies { implementation 'com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6' + // Shared preferences (encrypted) + implementation 'at.favre.lib:armadillo:0.9.0' + testImplementation 'junit:junit:4.13.2' testImplementation "com.google.truth:truth:1.1.3" androidTestImplementation 'androidx.test.ext:junit:1.1.3' diff --git a/app/src/main/java/com/tangem/tap/common/compose/ComposeDialogManager.kt b/app/src/main/java/com/tangem/tap/common/compose/ComposeDialogManager.kt index 5031d9d835..fc496f1d9b 100644 --- a/app/src/main/java/com/tangem/tap/common/compose/ComposeDialogManager.kt +++ b/app/src/main/java/com/tangem/tap/common/compose/ComposeDialogManager.kt @@ -1,11 +1,25 @@ package com.tangem.tap.common.compose import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.* -import androidx.compose.runtime.* +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -20,7 +34,7 @@ import com.tangem.domain.DomainDialog import com.tangem.domain.redux.domainStore import com.tangem.domain.redux.global.DomainGlobalAction import com.tangem.domain.redux.global.DomainGlobalState -import com.tangem.tap.common.moduleMessage.ModuleMessageConverter +import com.tangem.tap.domain.moduleMessage.ModuleMessageConverter import com.tangem.tap.features.tokens.addCustomToken.compose.SelectTokenNetworkDialog import com.tangem.wallet.R import org.rekotlin.StoreSubscriber @@ -61,7 +75,7 @@ private fun ShowTheDialog(dialogState: MutableState) { when (val dialog = dialogState.value) { is DomainDialog.DialogError -> ErrorDialog( title = stringResource(id = R.string.common_error), - body = errorConverter.convert(dialog.error), + body = errorConverter.convert(dialog.error).message, onDismissRequest ) is DomainDialog.SelectTokenDialog -> SelectTokenNetworkDialog(dialog, onDismissRequest) diff --git a/app/src/main/java/com/tangem/tap/common/compose/OutlinedTextFieldWidget.kt b/app/src/main/java/com/tangem/tap/common/compose/OutlinedTextFieldWidget.kt index 4eb6f5e2ed..deacbaf05f 100644 --- a/app/src/main/java/com/tangem/tap/common/compose/OutlinedTextFieldWidget.kt +++ b/app/src/main/java/com/tangem/tap/common/compose/OutlinedTextFieldWidget.kt @@ -1,6 +1,11 @@ package com.tangem.tap.common.compose -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -26,7 +31,7 @@ import com.tangem.common.module.ModuleError import com.tangem.domain.common.form.Field import com.tangem.tap.common.CompositionLogger import com.tangem.tap.common.compose.extensions.stringResourceDefault -import com.tangem.tap.common.moduleMessage.ModuleMessageConverter +import com.tangem.tap.domain.moduleMessage.ModuleMessageConverter /** [REDACTED_AUTHOR] @@ -67,7 +72,7 @@ fun OutlinedTextFieldWidget( debounce = debounceTextChanges, visualTransformation = visualTransformation, keyboardOptions = keyboardOptions, - onTextChanged = onTextChanged + onTextChanged = onTextChanged, ) errorConverter?.let { AnimatedErrorView(error, it) } } @@ -105,7 +110,8 @@ private fun OutlinedProgressTextField( onValueChanged = { logger.log("DEBOUNCER: onValueChanged: >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> dispatch.toStore([$it])") onTextChanged(it) - }) + }, + ) logger.log("RECOMPOSE ---------------------------------------------------------------START [${logger.count}]") logger.log("RECOMPOSE --data: fieldData.value: [${fieldData}]") @@ -188,7 +194,10 @@ private fun AnimatedErrorView( exit = slideOutVertically() + fadeOut(), ) { error?.let { - ErrorView(errorConverter.convert(it), style = TextStyle(fontSize = 14.sp)) + ErrorView( + text = errorConverter.convert(it).message, + style = TextStyle(fontSize = 14.sp), + ) } } } @@ -203,15 +212,13 @@ fun OutlinedTextFieldWithErrorTest() { override val code: Int = 1, override val message: String = "Error message", override val data: Any? = null, - ) : ModuleError + ) : ModuleError() val modifier = Modifier .fillMaxWidth() .padding(16.dp) - Scaffold( - ) { - Column( - ) { + Scaffold { + Column { OutlinedTextFieldWidget( modifier = modifier, fieldData = Field.Data("", false), diff --git a/app/src/main/java/com/tangem/tap/common/compose/PinCodeWidget.kt b/app/src/main/java/com/tangem/tap/common/compose/PinCodeWidget.kt index 31c826f6be..ad7bbe794b 100644 --- a/app/src/main/java/com/tangem/tap/common/compose/PinCodeWidget.kt +++ b/app/src/main/java/com/tangem/tap/common/compose/PinCodeWidget.kt @@ -153,7 +153,7 @@ private val tangemPinConfig = PinViewConfig( .width(42.dp) .height(56.dp) .clip(RoundedCornerShape(8.dp)) - .background(Color(0xFFF5F5F5)), + .background(Color(0xFFF0F0F0)), pinBoxPadding = 6.dp, pinTextModifier = Modifier, pinsCount = 4, diff --git a/app/src/main/java/com/tangem/tap/common/compose/Warning.kt b/app/src/main/java/com/tangem/tap/common/compose/Warning.kt index 8ea4eceef2..07c82d1384 100644 --- a/app/src/main/java/com/tangem/tap/common/compose/Warning.kt +++ b/app/src/main/java/com/tangem/tap/common/compose/Warning.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.tangem.common.module.ModuleMessage -import com.tangem.tap.common.moduleMessage.ModuleMessageConverter +import com.tangem.tap.domain.moduleMessage.ModuleMessageConverter import com.tangem.wallet.R /** @@ -42,7 +42,7 @@ fun AddCustomTokenWarning( ) SpacerH8() Text( - text = converter.convert(warning), + text = converter.convert(warning).message, color = colorResource(id = R.color.white), fontSize = 13.sp, lineHeight = 18.sp diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Context.kt b/app/src/main/java/com/tangem/tap/common/extensions/Context.kt index 60345e1ab7..cba31fe77c 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/Context.kt +++ b/app/src/main/java/com/tangem/tap/common/extensions/Context.kt @@ -1,6 +1,8 @@ package com.tangem.tap.common.extensions import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat fun Context.readFile(fileName: String): String = this.openFileInput(fileName).bufferedReader().readText() @@ -13,4 +15,8 @@ fun Context.rewriteFile(content: String, fileName: String) { fun Context.readAssetAsString(fileName: String): String { return this.assets.open("$fileName.json").bufferedReader().readText() +} + +fun Context.isPermissionGranted(permission: String): Boolean { + return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Store.kt b/app/src/main/java/com/tangem/tap/common/extensions/Store.kt index 7a0488d1a5..d88560669e 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/Store.kt +++ b/app/src/main/java/com/tangem/tap/common/extensions/Store.kt @@ -13,16 +13,57 @@ import kotlinx.coroutines.launch import org.rekotlin.Action import org.rekotlin.Store +/** + * Dispatch action with creating the new coroutine with the Main dispatcher + */ fun Store<*>.dispatchOnMain(action: Action) { scope.launch(Dispatchers.Main) { store.dispatch(action) } } +fun Store<*>.dispatchNotification(resId: Int) { + dispatchOnMain(GlobalAction.ShowNotification(resId)) +} + +fun Store<*>.dispatchToastNotification(resId: Int) { + dispatchOnMain(GlobalAction.ShowToastNotification(resId)) +} + +fun Store<*>.dispatchErrorNotification(error: TapError) { + dispatchOnMain(GlobalAction.ShowErrorNotification(error)) +} + +/** + * @param fatal used to indicate errors that should not normally occur + */ +fun Store<*>.dispatchDebugErrorNotification(message: String, fatal: Boolean = false) { + val prefix = if (fatal) "FATAL ERROR: " else "DEBUG ERROR: " + dispatchDebugErrorNotification(TapError.CustomError("$prefix $message")) +} + +fun Store<*>.dispatchDebugErrorNotification(error: TapError) { + dispatchOnMain(GlobalAction.DebugShowErrorNotification(error)) +} + +fun Store<*>.dispatchDialogShow(dialog: StateDialog) { + dispatchOnMain(GlobalAction.ShowDialog(dialog)) +} + +fun Store<*>.dispatchDialogHide() { + dispatchOnMain(GlobalAction.HideDialog) +} + +/** + * Dispatch action inside a coroutine with the Main dispatcher + */ suspend fun dispatchOnMain(vararg actions: Action) { withMainContext { actions.forEach { store.dispatch(it) } } } +/** + * Dispatch action + */ suspend fun Store<*>.onCardScanned(scanResponse: ScanResponse) { store.state.globalState.tapWalletManager.onCardScanned(scanResponse) } @@ -33,49 +74,4 @@ fun Store<*>.dispatchOpenUrl(url: String) { fun Store<*>.dispatchShare(url: String) { store.dispatch(NavigationAction.Share(url)) -} - -fun Store<*>.dispatchNotification(resId: Int) { - scope.launch(Dispatchers.Main) { - store.dispatch(GlobalAction.ShowNotification(resId)) - } -} - -fun Store<*>.dispatchToastNotification(resId: Int) { - scope.launch(Dispatchers.Main) { - store.dispatch(GlobalAction.ShowToastNotification(resId)) - } -} - - -fun Store<*>.dispatchErrorNotification(error: TapError) { - scope.launch(Dispatchers.Main) { - store.dispatch(GlobalAction.ShowErrorNotification(error)) - } -} - -/** - * @param fatal used to indicate errors that should not normally have occurred - */ -fun Store<*>.dispatchDebugErrorNotification(message: String, fatal: Boolean = false) { - val prefix = if (fatal) "FATAL ERROR: " else "DEBUG ERROR: " - dispatchDebugErrorNotification(TapError.CustomError("$prefix $message")) -} - -fun Store<*>.dispatchDebugErrorNotification(error: TapError) { - scope.launch(Dispatchers.Main) { - store.dispatch(GlobalAction.DebugShowErrorNotification(error)) - } -} - -fun Store<*>.dispatchDialogShow(dialog: StateDialog) { - scope.launch(Dispatchers.Main) { - store.dispatch(GlobalAction.ShowDialog(dialog)) - } -} - -fun Store<*>.dispatchDialogHide() { - scope.launch(Dispatchers.Main) { - store.dispatch(GlobalAction.HideDialog) - } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt b/app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt index 311932f7c1..76144c6132 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt +++ b/app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt @@ -1,5 +1,6 @@ package com.tangem.tap.common.extensions +import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.BlockchainSdkError import com.tangem.blockchain.common.Wallet import com.tangem.blockchain.common.WalletManager @@ -69,4 +70,12 @@ fun WalletManager?.getAddressData(): AddressData? { val addressDataList = wallet.createAddressesData() return if (addressDataList.isEmpty()) null else addressDataList[0] +} + +fun WalletManager.Companion.stub(): T { + val wallet = Wallet(Blockchain.Unknown, setOf(), Wallet.PublicKey(byteArrayOf(), null, null), setOf()) + return object : WalletManager(wallet) { + override val currentHost: String = "" + override suspend fun update() {} + } as T } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/moduleMessage/ModuleMessageConverter.kt b/app/src/main/java/com/tangem/tap/common/moduleMessage/ModuleMessageConverter.kt deleted file mode 100644 index a7247ebe20..0000000000 --- a/app/src/main/java/com/tangem/tap/common/moduleMessage/ModuleMessageConverter.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tangem.tap.common.moduleMessage - -import android.content.Context -import com.tangem.common.module.ModuleMessage -import com.tangem.common.module.ModuleMessageConverter -import com.tangem.domain.DomainModuleMessage -import com.tangem.tap.common.moduleMessage.domain.DomainMessageConverter - -class ModuleMessageConverter( - private val context: Context -) : ModuleMessageConverter { - - override fun convert(message: ModuleMessage): String { - val convertedMessage = when (message) { - is DomainModuleMessage -> DomainMessageConverter(context).convert(message) - else -> null - } - return convertedMessage ?: convertUnknownMessage(message) - } - - private fun convertUnknownMessage(message: ModuleMessage): String { - return "Unknown message: ${message::class.java.simpleName}" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/moduleMessage/domain/DomainErrorConverters.kt b/app/src/main/java/com/tangem/tap/common/moduleMessage/domain/DomainErrorConverters.kt deleted file mode 100644 index 7f5221041e..0000000000 --- a/app/src/main/java/com/tangem/tap/common/moduleMessage/domain/DomainErrorConverters.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.tangem.tap.common.moduleMessage.domain - -import android.content.Context -import com.tangem.common.module.ModuleMessageConverter -import com.tangem.domain.AddCustomTokenError -import com.tangem.domain.DomainError -import com.tangem.wallet.R - -/** -[REDACTED_AUTHOR] - */ -class DomainErrorConverter( - private val context: Context -) : ModuleMessageConverter { - override fun convert(message: DomainError): String? = when (message) { - is AddCustomTokenError -> AddCustomTokenConverter(context).convert(message) - else -> null - } -} - -private class AddCustomTokenConverter( - private val context: Context -) : ModuleMessageConverter { - - override fun convert(message: DomainError): String? { - val customTokenError = (message as? AddCustomTokenError) ?: throw UnsupportedOperationException() - - val rawMessage = when (customTokenError) { - AddCustomTokenError.Warning.PotentialScamToken -> R.string.custom_token_validation_error_not_found - AddCustomTokenError.Warning.TokenAlreadyAdded -> R.string.custom_token_validation_error_already_added - AddCustomTokenError.Warning.UnsupportedSolanaToken -> R.string.alert_manage_tokens_unsupported_message - AddCustomTokenError.InvalidContractAddress -> R.string.custom_token_creation_error_invalid_contract_address - AddCustomTokenError.NetworkIsNotSelected -> R.string.custom_token_creation_error_network_not_selected - AddCustomTokenError.InvalidDerivationPath -> R.string.custom_token_creation_error_invalid_derivation_path - AddCustomTokenError.InvalidDecimalsCount -> { - context.getString(R.string.custom_token_creation_error_wrong_decimals, 30) - } - AddCustomTokenError.FieldIsEmpty -> R.string.custom_token_creation_error_required_field - else -> null - } - - return when (rawMessage) { - is Int -> context.getString(rawMessage) - is String -> rawMessage - else -> null - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/moduleMessage/domain/DomainMessageConverter.kt b/app/src/main/java/com/tangem/tap/common/moduleMessage/domain/DomainMessageConverter.kt deleted file mode 100644 index 3afb79b219..0000000000 --- a/app/src/main/java/com/tangem/tap/common/moduleMessage/domain/DomainMessageConverter.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.tap.common.moduleMessage.domain - -import android.content.Context -import com.tangem.common.module.ModuleMessageConverter -import com.tangem.domain.DomainError -import com.tangem.domain.DomainModuleMessage - -/** -[REDACTED_AUTHOR] - */ -class DomainMessageConverter( - private val context: Context -) : ModuleMessageConverter { - override fun convert(message: DomainModuleMessage): String? { - return when (message) { - is DomainError -> DomainErrorConverter(context).convert(message) - else -> null - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt index 6f20496105..af79cb81e1 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt @@ -24,6 +24,7 @@ import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsState import com.tangem.tap.features.onboarding.products.wallet.redux.BackupMiddleware import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletMiddleware import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletState +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayMiddleware import com.tangem.tap.features.send.redux.middlewares.SendMiddleware import com.tangem.tap.features.send.redux.states.SendState import com.tangem.tap.features.shop.redux.ShopMiddleware @@ -66,6 +67,7 @@ data class AppState( HomeMiddleware.handler, OnboardingNoteMiddleware.handler, OnboardingWalletMiddleware.handler, + OnboardingSaltPayMiddleware.handler, OnboardingOtherCardsMiddleware.handler, WalletMiddleware().walletMiddleware, TwinCardsMiddleware.handler, diff --git a/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt index 1337effab2..30b127fda1 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt @@ -12,6 +12,7 @@ val logMiddleware: Middleware = { dispatch, appState -> { action -> if (LogConfig.storeAction) { Timber.d("Dispatch action: $action") + // printOnboardingWalletState() } nextDispatch(action) } diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt index 4927405ccd..0b285f8da9 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt @@ -36,9 +36,10 @@ sealed class GlobalAction : Action { data class ShowDialog(val stateDialog: StateDialog) : GlobalAction() object HideDialog : GlobalAction() - sealed class Onboarding { - data class Start(val scanResponse: ScanResponse?, val fromHomeScreen: Boolean = true) : GlobalAction() - object Stop : GlobalAction() + sealed class Onboarding : GlobalAction() { + data class Start(val scanResponse: ScanResponse, val canSkipBackup: Boolean = true) : Onboarding() + data class StartForUnfinishedBackup(val isSaltPayBackup: Boolean) : Onboarding() + object Stop : Onboarding() } data class ScanCard( diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt index 449d47ae10..d1f9043ca2 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt @@ -18,14 +18,13 @@ fun globalReducer(action: Action, state: AppState): GlobalState { globalState.copy(resources = action.resources) } is GlobalAction.Onboarding.Start -> { - val onboardingManager = if (action.scanResponse != null) { - val usedCardsPrefStorage = preferencesStorage.usedCardsPrefStorage - OnboardingManager(action.scanResponse, usedCardsPrefStorage) - } else { - null - } + val usedCardsPrefStorage = preferencesStorage.usedCardsPrefStorage + val onboardingManager = OnboardingManager(action.scanResponse, usedCardsPrefStorage) globalState.copy(onboardingState = OnboardingState(true, onboardingManager)) } + is GlobalAction.Onboarding.StartForUnfinishedBackup -> { + globalState.copy(onboardingState = OnboardingState(true, null)) + } is GlobalAction.Onboarding.Stop -> { globalState.copy(onboardingState = OnboardingState(false)) } diff --git a/app/src/main/java/com/tangem/tap/common/toggleWidget/IndeterminateProgressButtonWidget.kt b/app/src/main/java/com/tangem/tap/common/toggleWidget/IndeterminateProgressButtonWidget.kt index b4eacfff4e..cc57977202 100644 --- a/app/src/main/java/com/tangem/tap/common/toggleWidget/IndeterminateProgressButtonWidget.kt +++ b/app/src/main/java/com/tangem/tap/common/toggleWidget/IndeterminateProgressButtonWidget.kt @@ -11,17 +11,17 @@ import com.tangem.tap.features.wallet.redux.ProgressState [REDACTED_AUTHOR] */ open class IndeterminateProgressButtonWidget( - private val button: MaterialButton, - private val progress: View, - initialState: ProgressState = ProgressState.Done + private val button: MaterialButton, + private val progress: View, + initialState: ProgressState = ProgressState.Done, ) : ViewStateWidget { - private var initialButtonText: CharSequence = button.text - private var icon: Drawable? = null + private var text: CharSequence = button.text + private var icon: Drawable? = button.icon + private var iconGravity: Int? = button.iconGravity init { - icon = button.icon - changeState(initialState) + if (initialState != ProgressState.Done) changeState(initialState) } override val mainView: View = button @@ -37,15 +37,18 @@ open class IndeterminateProgressButtonWidget( protected open fun switchToNone() { button.isClickable = true + button.text = text button.icon = icon - button.text = initialButtonText + iconGravity?.let { button.iconGravity = it } + progress.hide() } protected open fun switchToProgress() { button.isClickable = false - button.icon = null button.text = "" + button.icon = null + progress.show() } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/TangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/TangemSdkManager.kt index 34e58f0ba7..9162ce8ce2 100644 --- a/app/src/main/java/com/tangem/tap/domain/TangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/TangemSdkManager.kt @@ -3,6 +3,7 @@ package com.tangem.tap.domain import CreateProductWalletTask import CreateProductWalletTaskResponse import android.content.Context +import androidx.annotation.StringRes import com.tangem.Message import com.tangem.TangemSdk import com.tangem.blockchain.common.Blockchain @@ -177,6 +178,10 @@ class TangemSdkManager(private val tangemSdk: TangemSdk, private val context: Co } } + fun getString(@StringRes stringResId: Int, vararg formatArgs: Any?): String { + return context.getString(stringResId, formatArgs) + } + companion object { val config = Config( linkedTerminal = true, diff --git a/app/src/main/java/com/tangem/tap/domain/configurable/config/ConfigManager.kt b/app/src/main/java/com/tangem/tap/domain/configurable/config/ConfigManager.kt index 62f367cb33..9d0ae5a366 100644 --- a/app/src/main/java/com/tangem/tap/domain/configurable/config/ConfigManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/configurable/config/ConfigManager.kt @@ -4,6 +4,7 @@ import com.tangem.blockchain.common.BlockchainSdkConfig import com.tangem.tap.common.shop.shopify.ShopifyShop import com.tangem.tap.common.zendesk.ZendeskConfig import com.tangem.tap.domain.configurable.Loader +import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayConfig /** [REDACTED_AUTHOR] @@ -22,6 +23,7 @@ data class Config( val isCreatingTwinCardsAllowed: Boolean = false, val shopify: ShopifyShop? = null, val zendesk: ZendeskConfig? = null, + val saltPayConfig: SaltPayConfig? = null, ) class ConfigManager { @@ -90,6 +92,7 @@ class ConfigManager { appsFlyerDevKey = values.appsFlyerDevKey, shopify = values.shopifyShop, zendesk = values.zendesk, + saltPayConfig = values.saltPay, ) defaultConfig = defaultConfig.copy( coinMarketCapKey = values.coinMarketCapKey, @@ -106,6 +109,7 @@ class ConfigManager { appsFlyerDevKey = values.appsFlyerDevKey, shopify = values.shopifyShop, zendesk = values.zendesk, + saltPayConfig = values.saltPay, ) } diff --git a/app/src/main/java/com/tangem/tap/domain/configurable/config/JsonModels.kt b/app/src/main/java/com/tangem/tap/domain/configurable/config/JsonModels.kt index 6e119fc5e6..70c7ca73db 100644 --- a/app/src/main/java/com/tangem/tap/domain/configurable/config/JsonModels.kt +++ b/app/src/main/java/com/tangem/tap/domain/configurable/config/JsonModels.kt @@ -2,32 +2,34 @@ package com.tangem.tap.domain.configurable.config import com.tangem.tap.common.shop.shopify.ShopifyShop import com.tangem.tap.common.zendesk.ZendeskConfig +import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayConfig /** [REDACTED_AUTHOR] */ class FeatureModel( - val isWalletPayIdEnabled: Boolean, - val isTopUpEnabled: Boolean, - val isSendingToPayIdEnabled: Boolean, - val isCreatingTwinCardsAllowed: Boolean, + val isWalletPayIdEnabled: Boolean, + val isTopUpEnabled: Boolean, + val isSendingToPayIdEnabled: Boolean, + val isCreatingTwinCardsAllowed: Boolean, ) class ConfigValueModel( - val coinMarketCapKey: String, - val mercuryoWidgetId: String, - val mercuryoSecret: String, - val moonPayApiKey: String, - val moonPayApiSecretKey: String, - val blockchairApiKey: String?, - val blockchairAuthorizationToken: String?, - val blockcypherTokens: Set?, - val infuraProjectId: String?, - val appsFlyerDevKey: String, - val shopifyShop: ShopifyShop?, - val zendesk: ZendeskConfig?, - val tronGridApiKey: String, + val coinMarketCapKey: String, + val mercuryoWidgetId: String, + val mercuryoSecret: String, + val moonPayApiKey: String, + val moonPayApiSecretKey: String, + val blockchairApiKey: String?, + val blockchairAuthorizationToken: String?, + val blockcypherTokens: Set?, + val infuraProjectId: String?, + val appsFlyerDevKey: String, + val shopifyShop: ShopifyShop?, + val zendesk: ZendeskConfig?, + val saltPay: SaltPayConfig, + val tronGridApiKey: String, ) class ConfigModel(val features: FeatureModel?, val configValues: ConfigValueModel?) { diff --git a/app/src/main/java/com/tangem/tap/domain/extensions/Card.kt b/app/src/main/java/com/tangem/tap/domain/extensions/Card.kt index 438b257916..5bd5575906 100644 --- a/app/src/main/java/com/tangem/tap/domain/extensions/Card.kt +++ b/app/src/main/java/com/tangem/tap/domain/extensions/Card.kt @@ -24,7 +24,7 @@ val Card.isWalletDataSupported: Boolean val Card.isMultiwalletAllowed: Boolean get() { - return !isTangemTwin() && !isStart2Coin && !isTangemNote() && !isSaltPay + return !isTangemTwin() && !isStart2Coin && !isTangemNote && !isSaltPay && (firmwareVersion >= FirmwareVersion.MultiWalletAvailable || getSingleWallet()?.curve == EllipticCurve.Secp256k1) } diff --git a/app/src/main/java/com/tangem/tap/domain/extensions/WalletManagerFactory.kt b/app/src/main/java/com/tangem/tap/domain/extensions/WalletManagerFactory.kt index 175fb62544..ec95e13dc6 100644 --- a/app/src/main/java/com/tangem/tap/domain/extensions/WalletManagerFactory.kt +++ b/app/src/main/java/com/tangem/tap/domain/extensions/WalletManagerFactory.kt @@ -1,16 +1,20 @@ package com.tangem.tap.domain.extensions +import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.DerivationParams import com.tangem.blockchain.common.DerivationStyle +import com.tangem.blockchain.common.Wallet import com.tangem.blockchain.common.WalletManager import com.tangem.blockchain.common.WalletManagerFactory import com.tangem.common.card.Card import com.tangem.common.card.CardWallet import com.tangem.common.card.EllipticCurve +import com.tangem.common.extensions.guard import com.tangem.common.extensions.hexToBytes import com.tangem.common.extensions.toMapKey import com.tangem.common.hdWallet.DerivationPath +import com.tangem.domain.common.SaltPayWorkaround import com.tangem.domain.common.ScanResponse import com.tangem.domain.common.TapWorkarounds.isTestCard import com.tangem.domain.common.TapWorkarounds.useOldStyleDerivation @@ -20,7 +24,7 @@ import com.tangem.tap.features.wallet.models.Currency fun WalletManagerFactory.makeWalletManagerForApp( scanResponse: ScanResponse, blockchain: Blockchain, - derivationParams: DerivationParams? + derivationParams: DerivationParams?, ): WalletManager? { val card = scanResponse.card if (card.isTestCard && blockchain.getTestnetVersion() == null) return null @@ -62,26 +66,26 @@ fun WalletManagerFactory.makeWalletManagerForApp( makeWalletManager( blockchain = environmentBlockchain, walletPublicKey = wallet.publicKey, - curve = wallet.curve + curve = wallet.curve, ) } } } fun WalletManagerFactory.makeWalletManagerForApp( - scanResponse: ScanResponse, blockchainNetwork: BlockchainNetwork + scanResponse: ScanResponse, blockchainNetwork: BlockchainNetwork, ): WalletManager? { return makeWalletManagerForApp( scanResponse, blockchain = blockchainNetwork.blockchain, - derivationParams = getDerivationParams(blockchainNetwork.derivationPath, scanResponse.card) + derivationParams = getDerivationParams(blockchainNetwork.derivationPath, scanResponse.card), ) } private fun getDerivationParams(derivationPath: String?, card: Card): DerivationParams? { return derivationPath?.let { DerivationParams.Custom( - DerivationPath(it) + DerivationPath(it), ) } ?: if (!card.settings.isHDWalletAllowed) { null @@ -93,17 +97,19 @@ private fun getDerivationParams(derivationPath: String?, card: Card): Derivation } fun WalletManagerFactory.makeWalletManagerForApp( - scanResponse: ScanResponse, currency: Currency + scanResponse: ScanResponse, + currency: Currency, ): WalletManager? { return makeWalletManagerForApp( scanResponse, blockchain = currency.blockchain, - derivationParams = getDerivationParams(currency.derivationPath, scanResponse.card) + derivationParams = getDerivationParams(currency.derivationPath, scanResponse.card), ) } fun WalletManagerFactory.makeWalletManagersForApp( - scanResponse: ScanResponse, blockchains: List, + scanResponse: ScanResponse, + blockchains: List, ): List { return blockchains .filter { it.isBlockchain() } @@ -122,7 +128,7 @@ fun WalletManagerFactory.makePrimaryWalletManager( return makeWalletManagerForApp( scanResponse = scanResponse, blockchain = blockchain, - derivationParams = derivationParams + derivationParams = derivationParams, ) } @@ -132,4 +138,23 @@ private fun selectWallet(wallets: List): CardWallet? { 1 -> wallets[0] else -> wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } ?: wallets[0] } +} + +fun WalletManagerFactory.makeSaltPayWalletManager( + scanResponse: ScanResponse, +): EthereumWalletManager { + val blockchain = scanResponse.getBlockchain() + if (blockchain != Blockchain.SaltPay && blockchain != Blockchain.SaltPayTestnet) + throw IllegalArgumentException() + + val token = SaltPayWorkaround.tokenFrom(blockchain) + val cardWallet = scanResponse.card.wallets.firstOrNull().guard { + throw NullPointerException("SaltPay card must have one wallet at least") + } + + return makeWalletManager( + blockchain = blockchain, + publicKey = Wallet.PublicKey(cardWallet.publicKey, null, null), + tokens = listOf(token), + ) as EthereumWalletManager } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/statePrinter/OnboardingWalletStateConverter.kt b/app/src/main/java/com/tangem/tap/domain/statePrinter/OnboardingWalletStateConverter.kt new file mode 100644 index 0000000000..858dc8b5d1 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/statePrinter/OnboardingWalletStateConverter.kt @@ -0,0 +1,88 @@ +package com.tangem.tap.domain.statePrinter + +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson +import com.tangem.common.json.MoshiJsonConverter +import com.tangem.domain.redux.state.StringStateConverter +import com.tangem.tap.common.redux.AppState +import com.tangem.tap.features.onboarding.products.wallet.redux.BackupStep +import com.tangem.tap.store +import timber.log.Timber +import java.math.BigInteger + +/** +[REDACTED_AUTHOR] + */ +class OnboardingWalletStateConverter : StringStateConverter { + private val converter = MoshiJsonConverter( + MoshiJsonConverter.getTangemSdkAdapters() + internalAdapters(), + MoshiJsonConverter.getTangemSdkTypedAdapters(), + ) + + private fun internalAdapters(): List { + return listOf( + BackupStepAdapter(), + BigIntegerAdapter(), + + ) + } + + override fun convert(stateHolder: AppState): String { + val model = stateHolder.onboardingWalletState + val json = converter.prettyPrint(model) + + return json + } +} + +fun printOnboardingWalletState() { + val stringState = OnboardingWalletStateConverter().convert(store.state) + Timber.d(stringState) +} + +class BackupStepAdapter { + @ToJson + fun toJson(src: BackupStep): String { + return when (src) { + BackupStep.AddBackupCards -> "AddBackupCards" + BackupStep.EnterAccessCode -> "EnterAccessCode" + BackupStep.Finished -> "Finished" + BackupStep.InitBackup -> "InitBackup" + BackupStep.ReenterAccessCode -> "ReenterAccessCode" + BackupStep.ScanOriginCard -> "ScanOriginCard" + BackupStep.SetAccessCode -> "SetAccessCode" + is BackupStep.WriteBackupCard -> "WriteBackupCard[${src.cardNumber}]" + BackupStep.WritePrimaryCard -> "WritePrimaryCard" + } + } + + @FromJson + fun fromJson(json: String): BackupStep { + return when (json) { + "AddBackupCards" -> BackupStep.AddBackupCards + "EnterAccessCode" -> BackupStep.EnterAccessCode + "Finished" -> BackupStep.Finished + "InitBackup" -> BackupStep.InitBackup + "ReenterAccessCode" -> BackupStep.ReenterAccessCode + "ScanOriginCard" -> BackupStep.ScanOriginCard + "SetAccessCode" -> BackupStep.SetAccessCode + "WriteBackupCard[1]" -> BackupStep.WriteBackupCard(1) + "WriteBackupCard[2]" -> BackupStep.WriteBackupCard(2) + "WriteBackupCard[3]" -> BackupStep.WriteBackupCard(3) + "WritePrimaryCard" -> BackupStep.WritePrimaryCard + else -> throw UnsupportedOperationException() + } + } +} + +class BigIntegerAdapter { + @ToJson + fun toJson(src: BigInteger): String { + return src.toString() + } + + @FromJson + fun fromJson(json: String): BigInteger { + return BigInteger(json) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt index cdef91880f..d18b590dfd 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt @@ -18,9 +18,9 @@ import com.tangem.domain.common.ScanResponse import com.tangem.domain.common.TapWorkarounds.isExcluded import com.tangem.domain.common.TapWorkarounds.isNotSupportedInThatRelease import com.tangem.domain.common.TapWorkarounds.isSaltPay +import com.tangem.domain.common.TapWorkarounds.isTangemTwins import com.tangem.domain.common.TapWorkarounds.useOldStyleDerivation import com.tangem.domain.common.TwinsHelper -import com.tangem.domain.common.isTangemTwins import com.tangem.operations.PreflightReadMode import com.tangem.operations.PreflightReadTask import com.tangem.operations.ScanTask @@ -31,10 +31,9 @@ import com.tangem.operations.issuerAndUserData.ReadIssuerDataCommand import com.tangem.tap.domain.TapSdkError import com.tangem.tap.domain.extensions.getPrimaryCurve import com.tangem.tap.domain.extensions.getSingleWallet -import com.tangem.tap.domain.tokens.UserTokensRepository import com.tangem.tap.domain.extensions.hasNoWallets -import com.tangem.tap.domain.extensions.isHdWalletAllowedByApp import com.tangem.tap.domain.extensions.isMultiwalletAllowed +import com.tangem.tap.domain.tokens.UserTokensRepository import com.tangem.tap.domain.tokens.models.BlockchainNetwork import com.tangem.tap.preferencesStorage @@ -60,7 +59,7 @@ class ScanProductTask( } val commandProcessor = when { - card.isTangemTwins() -> ScanTwinProcessor() + card.isTangemTwins -> ScanTwinProcessor() else -> ScanWalletProcessor(userTokensRepository, additionalBlockchainsToDerive) } commandProcessor.proceed(card, session) { processorResult -> @@ -84,8 +83,8 @@ class ScanProductTask( } private fun getErrorIfExcludedCard(card: Card): TangemError? { - if (card.isExcluded()) return TapSdkError.CardForDifferentApp - if (card.isNotSupportedInThatRelease()) return TapSdkError.CardNotSupportedByRelease + if (card.isExcluded) return TapSdkError.CardForDifferentApp + if (card.isNotSupportedInThatRelease) return TapSdkError.CardNotSupportedByRelease if (card.isSaltPay && card.hasNoWallets()) return TapSdkError.ScanPrimaryCard return null } @@ -111,6 +110,7 @@ private class ScanWalletProcessor( callback: (result: CompletionResult) -> Unit, ) { if (card.wallets.isEmpty() || !card.isMultiwalletAllowed) { + // match for the saltPay card to startLinkingForBackupIfNeeded(card, session, callback) return } @@ -166,13 +166,17 @@ private class ScanWalletProcessor( session: CardSession, callback: (result: CompletionResult) -> Unit, ) { + val productType = when(card.isSaltPay){ + true -> ProductType.SaltPay + else -> ProductType.Wallet + } val derivations = collectDerivations(card) if (derivations.isEmpty() || !card.settings.isHDWalletAllowed) { callback( CompletionResult.Success( ScanResponse( card = card, - productType = ProductType.Wallet, + productType = productType, walletData = session.environment.walletData, primaryCard = primaryCard, ), @@ -186,7 +190,7 @@ private class ScanWalletProcessor( is CompletionResult.Success -> { val response = ScanResponse( card = card, - productType = ProductType.Wallet, + productType = productType, walletData = session.environment.walletData, derivedKeys = result.data.entries, primaryCard = primaryCard, diff --git a/app/src/main/java/com/tangem/tap/domain/tokens/UserTokensRepository.kt b/app/src/main/java/com/tangem/tap/domain/tokens/UserTokensRepository.kt index e418215ed7..de30f616f6 100644 --- a/app/src/main/java/com/tangem/tap/domain/tokens/UserTokensRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/tokens/UserTokensRepository.kt @@ -93,17 +93,10 @@ class UserTokensRepository( private fun Card.getUserId(): String { val walletPublicKey = this.wallets.firstOrNull()?.publicKey ?: return "" - return calculateUserId(walletPublicKey) - } - - private fun calculateUserId(walletPublicKey: ByteArray): String { - val message = MESSAGE.toByteArray() - val keyHash = walletPublicKey.calculateSha256() - return message.calculateHmacSha256(keyHash).toHexString() + return UserWalletId(walletPublicKey).stringValue } companion object { - const val MESSAGE = "UserWalletID" fun init(context: Context, tangemTechService: TangemTechService): UserTokensRepository { val fileReader = AndroidFileReader(context) val oldUserTokensRepository = OldUserTokensRepository( @@ -114,4 +107,16 @@ class UserTokensRepository( return UserTokensRepository(storageService, networkService) } } +} + +data class UserWalletId( + val walletPublicKey: ByteArray, +) { + val stringValue: String = calculateUserId(walletPublicKey) + + private fun calculateUserId(walletPublicKey: ByteArray): String { + val message = "UserWalletID".toByteArray() + val keyHash = walletPublicKey.calculateSha256() + return message.calculateHmacSha256(keyHash).toHexString() + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt index fa96a81962..cc9ccef1b6 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt @@ -2,7 +2,7 @@ package com.tangem.tap.features.details.redux import com.tangem.common.CompletionResult import com.tangem.common.core.TangemSdkError -import com.tangem.domain.common.isTangemTwins +import com.tangem.domain.common.TapWorkarounds.isTangemTwins import com.tangem.tap.common.analytics.Analytics import com.tangem.tap.common.analytics.AnalyticsParam import com.tangem.tap.common.extensions.dispatchNotification @@ -69,13 +69,8 @@ class DetailsMiddleware { } is DetailsAction.CreateBackup -> { store.state.detailsState.scanResponse?.let { + store.dispatch(GlobalAction.Onboarding.Start(it, canSkipBackup = false)) store.dispatch(NavigationAction.NavigateTo(AppScreen.OnboardingWallet)) - store.dispatch( - GlobalAction.Onboarding.Start( - it, - fromHomeScreen = false, - ), - ) } } DetailsAction.ScanCard -> { @@ -98,7 +93,7 @@ class DetailsMiddleware { when (action) { is DetailsAction.ResetToFactory.Start -> { val card = store.state.detailsState.cardSettingsState?.card ?: return - if (card.isTangemTwins()) { + if (card.isTangemTwins) { store.dispatch(DetailsAction.ReCreateTwinsWallet) return } else { diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt index d2013dba98..ef4a0aa975 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt @@ -79,7 +79,7 @@ private fun prepareSecurityOptions(card: Card): ManageSecurityState { } } val allowedSecurityOptions = when { - card.isStart2Coin || card.isTangemNote() || card.isSaltPay -> { + card.isStart2Coin || card.isTangemNote || card.isSaltPay -> { EnumSet.of(SecurityOption.LongTap) } card.settings.isBackupAllowed -> { @@ -97,7 +97,7 @@ private fun prepareSecurityOptions(card: Card): ManageSecurityState { private fun isResetToFactoryAllowedByCard(card: Card): Boolean { val notAllowedByAnyWallet = card.wallets.any { it.settings.isPermanent } val notAllowedByCard = notAllowedByAnyWallet || - (card.isWalletDataSupported && (!card.isTangemNote() && !card.settings.isBackupAllowed)) || + (card.isWalletDataSupported && (!card.isTangemNote && !card.settings.isBackupAllowed)) || card.isSaltPay return !notAllowedByCard } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsViewModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsViewModel.kt index 60affefd8b..b238ffe8f2 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/CardSettingsViewModel.kt @@ -1,7 +1,7 @@ package com.tangem.tap.features.details.ui.cardsettings +import com.tangem.domain.common.TapWorkarounds.isTangemTwins import com.tangem.domain.common.getTwinCardIdForUser -import com.tangem.domain.common.isTangemTwins import com.tangem.tap.common.redux.AppState import com.tangem.tap.features.details.redux.CardSettingsState import com.tangem.tap.features.details.redux.DetailsAction @@ -20,7 +20,7 @@ class CardSettingsViewModel(private val store: Store) { }, ) } else { - val cardId = if (state.card.isTangemTwins()) { + val cardId = if (state.card.isTangemTwins) { state.card.getTwinCardIdForUser() } else { state.cardInfo.cardId @@ -30,7 +30,7 @@ class CardSettingsViewModel(private val store: Store) { CardInfo.Issuer(state.cardInfo.issuer), ) - if (!state.card.isTangemTwins()) { + if (!state.card.isTangemTwins) { cardDetails.add(CardInfo.SignedHashes(state.cardInfo.signedHashes.toString())) } cardDetails.add( diff --git a/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt b/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt index 27953640ea..487362e5c1 100644 --- a/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt @@ -81,16 +81,12 @@ class HomeFragment : Fragment(), StoreSubscriber { StoriesScreen( homeState, onScanButtonClick = { store.dispatch(HomeAction.ReadCard) }, - onShopButtonClick = { - store.dispatch( - HomeAction.GoToShop(store.state.globalState.userCountryCode) - ) - }, + onShopButtonClick = { store.dispatch(HomeAction.GoToShop(store.state.globalState.userCountryCode)) }, onSearchTokensClick = { store.dispatch(NavigationAction.NavigateTo(AppScreen.AddTokens)) store.dispatch(TokensAction.AllowToAddTokens(false)) store.dispatch(TokensAction.LoadCurrencies()) - } + }, ) } diff --git a/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreen.kt b/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreen.kt index 21adc5e516..a459a4d701 100644 --- a/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreen.kt @@ -49,7 +49,6 @@ import com.tangem.tap.features.home.compose.content.StoriesWeb3 import com.tangem.tap.features.home.compose.views.HomeButtons import com.tangem.tap.features.home.compose.views.StoriesProgressBar import com.tangem.tap.features.home.redux.HomeState -import com.tangem.tap.features.wallet.redux.ProgressState import com.tangem.wallet.R import kotlin.math.max @@ -74,7 +73,7 @@ fun StoriesScreen( } val isPressed = remember { mutableStateOf(false) } - val isPaused = isPressed.value || homeState.value.btnScanState.progressState == ProgressState.Loading + val isPaused = isPressed.value || homeState.value.scanInProgress val hideContent = remember { mutableStateOf(true) } @@ -88,10 +87,10 @@ fun StoriesScreen( Box( modifier = Modifier .fillMaxSize() - .background(Color(0xFF090E13)) + .background(Color(0xFF090E13)), ) { Row( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { Box( Modifier @@ -109,7 +108,7 @@ fun StoriesScreen( isPressed.value = false }, ) - } + }, ) Box( Modifier @@ -127,7 +126,7 @@ fun StoriesScreen( isPressed.value = false }, ) - } + }, ) } if (!isDarkBackground) { @@ -135,7 +134,7 @@ fun StoriesScreen( modifier = Modifier.fillMaxSize(), painter = painterResource(id = R.drawable.ic_overlay), contentDescription = null, - contentScale = ContentScale.FillBounds + contentScale = ContentScale.FillBounds, ) } @@ -165,7 +164,7 @@ fun StoriesScreen( .height(17.dp) .alpha(if (hideContent.value) 0f else 1f) .align(Alignment.Start), - colorFilter = if (isDarkBackground) null else ColorFilter.tint(Color.Black) + colorFilter = if (isDarkBackground) null else ColorFilter.tint(Color.Black), ) when (currentStep.value) { 1 -> FirstStoriesContent(isPaused, currentStep.duration()) { hideContent.value = it } @@ -190,27 +189,28 @@ fun StoriesScreen( .height(48.dp), colors = ButtonDefaults.textButtonColors( backgroundColor = Color.White, - contentColor = Color(0xFF080C10) - ) + contentColor = Color(0xFF080C10), + ), ) { Image( painter = painterResource(id = R.drawable.ic_search), - contentDescription = null + contentDescription = null, ) Text( text = stringResource(id = R.string.search_tokens_title), fontWeight = FontWeight.Medium, fontSize = 16.sp, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } HomeButtons( - isDarkBackground = isDarkBackground, modifier = Modifier .padding(start = 16.dp, top = 0.dp, end = 16.dp, bottom = 37.dp) .fillMaxWidth(), + isDarkBackground = isDarkBackground, + btnScanStateInProgress = homeState.value.btnScanStateInProgress, onScanButtonClick = onScanButtonClick, - onShopButtonClick = onShopButtonClick + onShopButtonClick = onShopButtonClick, ) } } diff --git a/app/src/main/java/com/tangem/tap/features/home/compose/views/HomeButtons.kt b/app/src/main/java/com/tangem/tap/features/home/compose/views/HomeButtons.kt index d49013db87..cb40186a87 100644 --- a/app/src/main/java/com/tangem/tap/features/home/compose/views/HomeButtons.kt +++ b/app/src/main/java/com/tangem/tap/features/home/compose/views/HomeButtons.kt @@ -3,8 +3,10 @@ package com.tangem.tap.features.home.compose.views import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -19,53 +21,89 @@ import com.tangem.wallet.R @Composable fun HomeButtons( - isDarkBackground: Boolean, modifier: Modifier = Modifier, + modifier: Modifier = Modifier, + isDarkBackground: Boolean, + btnScanStateInProgress: Boolean, onScanButtonClick: () -> Unit, - onShopButtonClick: () -> Unit + onShopButtonClick: () -> Unit, ) { val darkColorBackground = Color(0xFF26292E) val darkColorButton = Color(0xFF080C10) Row( horizontalArrangement = Arrangement.SpaceEvenly, - modifier = modifier + modifier = modifier, ) { - Button( + ProgressButton( modifier = Modifier .weight(1f) .height(48.dp), + inProgress = btnScanStateInProgress, + backgroundColor = if (isDarkBackground) darkColorBackground else Color.White, + contentColor = if (isDarkBackground) Color.White else darkColorButton, onClick = onScanButtonClick, - colors = ButtonDefaults.textButtonColors( - backgroundColor = if (isDarkBackground) darkColorBackground else Color.White, - contentColor = if (isDarkBackground) Color.White else darkColorButton - ) - ) { - Text( - text = stringResource(id = R.string.home_button_scan), - fontWeight = FontWeight.Medium, - fontSize = 16.sp, - textAlign = TextAlign.Center, - maxLines = 1 - ) - } + progressContent = { + CircularProgressIndicator( + modifier = Modifier.size(30.dp), + color = if (isDarkBackground) Color.White else darkColorBackground, + strokeWidth = 3.dp, + ) + }, + content = { + Text( + text = stringResource(id = R.string.home_button_scan), + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + textAlign = TextAlign.Center, + maxLines = 1, + ) + }, + ) SpacerS8() Button( modifier = Modifier .weight(1f) .height(48.dp), onClick = onShopButtonClick, + enabled = !btnScanStateInProgress, colors = ButtonDefaults.textButtonColors( backgroundColor = if (isDarkBackground) Color.White else darkColorBackground, - contentColor = if (isDarkBackground) darkColorButton else Color.White - ) + contentColor = if (isDarkBackground) darkColorButton else Color.White, + ), ) { Text( text = stringResource(id = R.string.home_button_order), fontWeight = FontWeight.Medium, fontSize = 16.sp, textAlign = TextAlign.Center, - maxLines = 1 + maxLines = 1, ) } } +} + +@Composable +fun ProgressButton( + modifier: Modifier, + backgroundColor: Color, + contentColor: Color, + onClick: () -> Unit, + inProgress: Boolean, + progressContent: @Composable (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + Button( + modifier = modifier, + onClick = onClick, + colors = ButtonDefaults.textButtonColors( + backgroundColor = backgroundColor, + contentColor = contentColor, + ), + ) { + if (progressContent != null && inProgress) { + progressContent() + } else { + content() + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/home/redux/HomeAction.kt b/app/src/main/java/com/tangem/tap/features/home/redux/HomeAction.kt index 1328156afa..c22bd6c043 100644 --- a/app/src/main/java/com/tangem/tap/features/home/redux/HomeAction.kt +++ b/app/src/main/java/com/tangem/tap/features/home/redux/HomeAction.kt @@ -6,6 +6,7 @@ import org.rekotlin.Action sealed class HomeAction : Action { // from ui object ReadCard : HomeAction() + data class ScanInProgress(val scanInProgress: Boolean) : HomeAction() data class GoToShop(val userCountryCode: String?) : HomeAction() // internal @@ -14,5 +15,4 @@ sealed class HomeAction : Action { data class SetTermsOfUseState(val isDisclaimerAccepted: Boolean) : HomeAction() data class ChangeScanCardButtonState(val state: IndeterminateProgressButton) : HomeAction() - } diff --git a/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt b/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt index bd208b2baf..5fcd71137a 100644 --- a/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt @@ -1,16 +1,20 @@ package com.tangem.tap.features.home.redux -import com.tangem.domain.common.TapWorkarounds.isSaltPayVisa +import com.tangem.common.core.TangemError +import com.tangem.common.services.Result +import com.tangem.domain.common.ScanResponse +import com.tangem.domain.common.extensions.withIOContext import com.tangem.domain.common.extensions.withMainContext import com.tangem.tap.DELAY_SDK_DIALOG_CLOSE import com.tangem.tap.common.analytics.AnalyticsEvent import com.tangem.tap.common.analytics.AnalyticsParam import com.tangem.tap.common.analytics.GetCardSourceParams import com.tangem.tap.common.entities.IndeterminateProgressButton +import com.tangem.tap.common.extensions.dispatchDebugErrorNotification +import com.tangem.tap.common.extensions.dispatchDialogShow import com.tangem.tap.common.extensions.dispatchOpenUrl import com.tangem.tap.common.extensions.onCardScanned -import com.tangem.tap.common.post -import com.tangem.tap.common.postUi +import com.tangem.tap.common.postUiDelayBg import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.common.redux.navigation.AppScreen @@ -20,13 +24,21 @@ import com.tangem.tap.features.home.BELARUS_COUNTRY_CODE import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE import com.tangem.tap.features.home.redux.HomeMiddleware.Companion.BUY_WALLET_URL import com.tangem.tap.features.onboarding.OnboardingHelper +import com.tangem.tap.features.onboarding.OnboardingSaltPayHelper import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsAction import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsStep +import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.SaltPayDialog +import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayRegistrationError +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayAction +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayState import com.tangem.tap.features.send.redux.states.ButtonState import com.tangem.tap.preferencesStorage import com.tangem.tap.scope import com.tangem.tap.store +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.rekotlin.Action +import org.rekotlin.DispatchFunction import org.rekotlin.Middleware class HomeMiddleware { @@ -37,98 +49,129 @@ class HomeMiddleware { } } -private val homeMiddleware: Middleware = { _, _ -> +private val homeMiddleware: Middleware = { dispatch, state -> { next -> { action -> - when (action) { - is HomeAction.Init -> { - store.dispatch(GlobalAction.RestoreAppCurrency) - store.dispatch(GlobalAction.ExchangeManager.Init) - store.dispatch(HomeAction.SetTermsOfUseState(preferencesStorage.wasDisclaimerAccepted())) - store.dispatch(GlobalAction.FetchUserCountry) - } - is HomeAction.ShouldScanCardOnResume -> { - if (action.shouldScanCard) { - store.dispatch(HomeAction.ShouldScanCardOnResume(false)) - postUi(700) { store.dispatch(HomeAction.ReadCard) } - } - } - is HomeAction.ReadCard -> { - handleReadCard() -// store.dispatch(NavigationAction.NavigateTo(AppScreen.AddCustomTokens)) - } - is HomeAction.GoToShop -> { - when (action.userCountryCode) { - RUSSIA_COUNTRY_CODE, BELARUS_COUNTRY_CODE -> - store.dispatchOpenUrl(BUY_WALLET_URL) - else -> store.dispatch(NavigationAction.NavigateTo(AppScreen.Shop)) - } - store.state.globalState.analyticsHandlers?.triggerEvent( - event = AnalyticsEvent.GET_CARD, - params = mapOf(AnalyticsParam.SOURCE.param to GetCardSourceParams.WELCOME.param), - ) - } - } + handleHomeAction(state, action, dispatch) next(action) } } } -private fun handleReadCard() { - if (!preferencesStorage.wasDisclaimerAccepted()) { - store.dispatch(NavigationAction.NavigateTo(AppScreen.Disclaimer)) - } else { - changeButtonState(ButtonState.PROGRESS) - store.dispatch( - GlobalAction.ScanCard( - onSuccess = { scanResponse -> - store.state.globalState.tapWalletManager.updateConfigManager(scanResponse) - store.dispatch(TwinCardsAction.IfTwinsPrepareState(scanResponse)) - - // TODO: SaltPay: temporary excluding backup process for Visa cards - if (scanResponse.card.isSaltPayVisa) { - scope.launch { - store.onCardScanned(scanResponse) - withMainContext { - navigateTo(AppScreen.Wallet, null) - } - } - return@ScanCard - } - - if (OnboardingHelper.isOnboardingCase(scanResponse)) { - val navigateTo = OnboardingHelper.whereToNavigate(scanResponse) - store.dispatch(GlobalAction.Onboarding.Start(scanResponse)) - navigateTo(navigateTo) - } else { - scope.launch { - withMainContext { - if (scanResponse.twinsIsTwinned() && !preferencesStorage.wasTwinsOnboardingShown()) { - store.dispatch(TwinCardsAction.SetStepOfScreen(TwinCardsStep.WelcomeOnly)) - navigateTo(AppScreen.OnboardingTwins) - } else { - navigateTo(AppScreen.Wallet, null) - } - } - store.onCardScanned(scanResponse) - } - } - }, - onFailure = { - changeButtonState(ButtonState.ENABLED) - }, - ), - ) +private fun handleHomeAction(appState: () -> AppState?, action: Action, dispatch: DispatchFunction) { + when (action) { + is HomeAction.Init -> { + store.dispatch(GlobalAction.RestoreAppCurrency) + store.dispatch(GlobalAction.ExchangeManager.Init) + store.dispatch(HomeAction.SetTermsOfUseState(preferencesStorage.wasDisclaimerAccepted())) + store.dispatch(GlobalAction.FetchUserCountry) + } + is HomeAction.ShouldScanCardOnResume -> { + if (action.shouldScanCard) { + store.dispatch(HomeAction.ShouldScanCardOnResume(false)) + postUiDelayBg(700) { store.dispatch(HomeAction.ReadCard) } + } + } + is HomeAction.ReadCard -> { + if (!preferencesStorage.wasDisclaimerAccepted()) { + store.dispatch(NavigationAction.NavigateTo(AppScreen.Disclaimer)) + } else { + changeButtonState(ButtonState.PROGRESS) + val scanCardAction = GlobalAction.ScanCard( + onSuccess = ::onScanSuccess, + onFailure = ::onScanFailure, + ) + store.dispatch(HomeAction.ScanInProgress(true)) + postUiDelayBg(300) { store.dispatch(scanCardAction) } + } + } + is HomeAction.GoToShop -> { + when (action.userCountryCode) { + RUSSIA_COUNTRY_CODE, BELARUS_COUNTRY_CODE -> store.dispatchOpenUrl(BUY_WALLET_URL) + else -> store.dispatch(NavigationAction.NavigateTo(AppScreen.Shop)) + } + store.state.globalState.analyticsHandlers?.triggerEvent( + event = AnalyticsEvent.GET_CARD, + params = mapOf(AnalyticsParam.SOURCE.param to GetCardSourceParams.WELCOME.param), + ) + } } } +private fun onScanSuccess(scanResponse: ScanResponse) { + val globalState = store.state.globalState + val tapWalletManager = globalState.tapWalletManager + tapWalletManager.updateConfigManager(scanResponse) + + store.dispatch(HomeAction.ScanInProgress(false)) + store.dispatch(TwinCardsAction.IfTwinsPrepareState(scanResponse)) + + if (scanResponse.isSaltPay()) { + scope.launch { + val result = OnboardingSaltPayHelper.isOnboardingCase(scanResponse) + delay(500) + withMainContext { + when (result) { + is Result.Success -> { + val isOnboardingCase = result.data + if (isOnboardingCase) { + store.dispatch(GlobalAction.Onboarding.Start(scanResponse, canSkipBackup = false)) + val (manager, config) = OnboardingSaltPayState.initDependency(scanResponse) + store.dispatch(OnboardingSaltPayAction.Init.SetDependencies(manager, config)) + store.dispatch(OnboardingSaltPayAction.Update) + // store.dispatch(OnboardingSaltPayAction.Init.DiscardBackupSteps) + navigateTo(AppScreen.OnboardingWallet) + } else { + navigateTo(AppScreen.Wallet) + withIOContext { store.onCardScanned(scanResponse) } + } + } + is Result.Failure -> { + changeButtonState(ButtonState.ENABLED) + when (val error = result.error) { + is SaltPayRegistrationError -> { + val dialog = when (error) { + is SaltPayRegistrationError.NoGas -> SaltPayDialog.NoFundsForActivation + else -> SaltPayDialog.RegistrationError(error) + } + store.dispatchDialogShow(dialog) + } + else -> { + store.dispatchDebugErrorNotification(error.localizedMessage ?: "SaltPay: Unknown error") + } + } + } + } + } + } + } else { + if (OnboardingHelper.isOnboardingCase(scanResponse)) { + store.dispatch(GlobalAction.Onboarding.Start(scanResponse, canSkipBackup = true)) + val appScreen = OnboardingHelper.whereToNavigate(scanResponse) + navigateTo(appScreen) + } else { + if (scanResponse.twinsIsTwinned() && !preferencesStorage.wasTwinsOnboardingShown()) { + store.dispatch(TwinCardsAction.SetStepOfScreen(TwinCardsStep.WelcomeOnly)) + navigateTo(AppScreen.OnboardingTwins) + } else { + navigateTo(AppScreen.Wallet, null) + } + scope.launch { store.onCardScanned(scanResponse) } + } + } +} + +private fun onScanFailure(error: TangemError) { + store.dispatch(HomeAction.ScanInProgress(false)) + changeButtonState(ButtonState.ENABLED) +} + private fun changeButtonState(state: ButtonState) { - val btnState = IndeterminateProgressButton(state) - store.dispatch(HomeAction.ChangeScanCardButtonState(btnState)) + store.dispatch(HomeAction.ChangeScanCardButtonState(IndeterminateProgressButton(state))) } private fun navigateTo(screen: AppScreen, transition: FragmentShareTransition? = null) { - post(DELAY_SDK_DIALOG_CLOSE) { + postUiDelayBg(DELAY_SDK_DIALOG_CLOSE) { changeButtonState(ButtonState.ENABLED) store.dispatch(NavigationAction.NavigateTo(screen, transition)) } diff --git a/app/src/main/java/com/tangem/tap/features/home/redux/HomeReducer.kt b/app/src/main/java/com/tangem/tap/features/home/redux/HomeReducer.kt index f4a7a9d407..511aea3ddc 100644 --- a/app/src/main/java/com/tangem/tap/features/home/redux/HomeReducer.kt +++ b/app/src/main/java/com/tangem/tap/features/home/redux/HomeReducer.kt @@ -15,6 +15,9 @@ private fun internalReduce(action: Action, state: AppState): HomeState { var state = state.homeState when (action) { + is HomeAction.ScanInProgress -> { + state = state.copy(scanInProgress = action.scanInProgress) + } is HomeAction.ShouldScanCardOnResume -> { state = state.copy(shouldScanCardOnResume = action.shouldScanCard) } diff --git a/app/src/main/java/com/tangem/tap/features/home/redux/HomeState.kt b/app/src/main/java/com/tangem/tap/features/home/redux/HomeState.kt index 38b19013d8..9ef6ea2b57 100644 --- a/app/src/main/java/com/tangem/tap/features/home/redux/HomeState.kt +++ b/app/src/main/java/com/tangem/tap/features/home/redux/HomeState.kt @@ -2,9 +2,15 @@ package com.tangem.tap.features.home.redux import com.tangem.tap.common.entities.IndeterminateProgressButton import com.tangem.tap.features.send.redux.states.ButtonState +import com.tangem.tap.features.wallet.redux.ProgressState import org.rekotlin.StateType data class HomeState( val shouldScanCardOnResume: Boolean = false, + val scanInProgress: Boolean = false, val btnScanState: IndeterminateProgressButton = IndeterminateProgressButton(ButtonState.ENABLED), -) : StateType \ No newline at end of file +) : StateType { + + val btnScanStateInProgress: Boolean + get() = btnScanState.progressState == ProgressState.Loading +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt index cd6f7c5197..f2227fcc32 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt @@ -2,7 +2,6 @@ package com.tangem.tap.features.onboarding import com.tangem.domain.common.ProductType import com.tangem.domain.common.ScanResponse -import com.tangem.domain.common.TapWorkarounds.isSaltPay import com.tangem.tap.common.redux.navigation.AppScreen import com.tangem.tap.domain.extensions.hasWallets import com.tangem.tap.preferencesStorage @@ -16,19 +15,14 @@ class OnboardingHelper { fun isOnboardingCase(response: ScanResponse): Boolean { val cardInfoStorage = preferencesStorage.usedCardsPrefStorage return when { - response.card.isSaltPay -> { - response.card.backupStatus?.isActive != true - } - response.productType == ProductType.Twins -> { + response.isTangemTwins() -> { if (!response.twinsIsTwinned()) { true } else { cardInfoStorage.activationIsStarted(response.card.cardId) } } - response.card.hasWallets() -> { - cardInfoStorage.activationIsStarted(response.card.cardId) - } + response.card.hasWallets() -> cardInfoStorage.activationIsStarted(response.card.cardId) else -> true } } diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingSaltPayHelper.kt b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingSaltPayHelper.kt new file mode 100644 index 0000000000..8b56bbc65a --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingSaltPayHelper.kt @@ -0,0 +1,34 @@ +package com.tangem.tap.features.onboarding + +import com.tangem.common.services.Result +import com.tangem.domain.common.ScanResponse +import com.tangem.domain.common.extensions.successOr +import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayRegistrationError +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayState +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.SaltPayRegistrationStep +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.toSaltPayStep + +/** +[REDACTED_AUTHOR] + */ +class OnboardingSaltPayHelper { + companion object { + + suspend fun isOnboardingCase(scanResponse: ScanResponse): Result { + return try { + val (manager, config) = OnboardingSaltPayState.initDependency(scanResponse) + val registrationResponseItem = manager.checkRegistration().successOr { return it } + val registrationStep = registrationResponseItem.toSaltPayStep() + manager.checkHasGas().successOr { return it } + + val isRegistrationOnboardingCase = registrationStep != SaltPayRegistrationStep.Finished + val isBackupOnboardingCase = scanResponse.card.backupStatus?.isActive == false + Result.Success(isRegistrationOnboardingCase || isBackupOnboardingCase) + } catch (ex: Exception) { + Result.Failure(ex) + } catch (error: SaltPayRegistrationError){ + Result.Failure(error) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/note/OnboardingNoteFragment.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/note/OnboardingNoteFragment.kt index b31dd02832..a81bc28e87 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/note/OnboardingNoteFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/note/OnboardingNoteFragment.kt @@ -49,7 +49,7 @@ class OnboardingNoteFragment : BaseOnboardingFragment() { startPostponedEnterTransition() binding.toolbar.setTitle(R.string.onboarding_title) - btnRefreshBalanceWidget = RefreshBalanceWidget(binding.onboardingTopContainer.onboardingMainContainer) + btnRefreshBalanceWidget = RefreshBalanceWidget(binding.onboardingTopContainer.onboardingWalletContainer) store.dispatch(OnboardingNoteAction.LoadCardArtwork) store.dispatch(OnboardingNoteAction.DetermineStepOfScreen) @@ -207,10 +207,10 @@ class OnboardingNoteFragment : BaseOnboardingFragment() { with(binding.onboardingTopContainer) { val constraintSet = ConstraintSet() constraintSet.clone(requireContext(), layoutId) - constraintSet.applyTo(onboardingMainContainer) + constraintSet.applyTo(onboardingWalletContainer) val transition = InternalNoteLayoutTransition() transition.interpolator = OvershootInterpolator() - TransitionManager.beginDelayedTransition(onboardingMainContainer, transition) + TransitionManager.beginDelayedTransition(onboardingWalletContainer, transition) } } } diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/otherCards/OnboardingOtherCardsFragment.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/otherCards/OnboardingOtherCardsFragment.kt index e2b1e015c4..bb09b3d434 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/otherCards/OnboardingOtherCardsFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/otherCards/OnboardingOtherCardsFragment.kt @@ -116,10 +116,10 @@ class OnboardingOtherCardsFragment : BaseOnboardingFragment() { 285f * deviceScaleFactorForWelcomeState } btnRefreshBalanceWidget = - RefreshBalanceWidget(binding.onboardingTopContainer.onboardingMainContainer) + RefreshBalanceWidget(binding.onboardingTopContainer.onboardingWalletContainer) binding.toolbar.title = getText(R.string.twins_recreate_toolbar) @@ -212,7 +212,7 @@ class TwinsCardsFragment : BaseOnboardingFragment() { chbUnderstand.hide() pbBinding.pbState.show() - binding.onboardingTopContainer.onboardingMainContainer.beginDelayedTransition() + binding.onboardingTopContainer.onboardingWalletContainer.beginDelayedTransition() when (previousStep) { TwinCardsStep.None -> { @@ -383,10 +383,10 @@ class TwinsCardsFragment : BaseOnboardingFragment() { with(binding.onboardingTopContainer) { val constraintSet = ConstraintSet() constraintSet.clone(requireContext(), layoutId) - constraintSet.applyTo(onboardingMainContainer) + constraintSet.applyTo(onboardingWalletContainer) val transition = InternalNoteLayoutTransition() transition.interpolator = OvershootInterpolator() - TransitionManager.beginDelayedTransition(onboardingMainContainer, transition) + TransitionManager.beginDelayedTransition(onboardingWalletContainer, transition) } } diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletAction.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletAction.kt index 8431644dbf..ce9a6b6c41 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletAction.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletAction.kt @@ -5,12 +5,14 @@ import org.rekotlin.Action sealed class OnboardingWalletAction : Action { object Init : OnboardingWalletAction() + object StartSaltPayCardActivation: OnboardingWalletAction() object GetToCreateWalletStep : OnboardingWalletAction() + object GetToSaltPayStep : OnboardingWalletAction() object CreateWallet : OnboardingWalletAction() object Done : OnboardingWalletAction() object FinishOnboarding : OnboardingWalletAction() - data class ProceedBackup(val isSaltPay: Boolean = false) : OnboardingWalletAction() + object ResumeBackup : OnboardingWalletAction() object LoadArtwork : OnboardingWalletAction() class SetArtworkUrl(val artworkUrl: String?) : OnboardingWalletAction() @@ -21,7 +23,7 @@ sealed class OnboardingWalletAction : Action { sealed class BackupAction : Action { object DetermineBackupStep : BackupAction() - data class IntroduceBackup(val isSaltPay: Boolean = false) : BackupAction() + object IntroduceBackup : BackupAction() object StartBackup : BackupAction() object DismissBackup : BackupAction() @@ -53,15 +55,13 @@ sealed class BackupAction : Action { data class SaveAccessCodeConfirmation(val accessCodeConfirmation: String) : BackupAction() object OnAccessCodeDialogClosed : BackupAction() - data class PrepareToWriteBackupCard(val cardNumber: Int) : BackupAction() - data class WriteBackupCard(val cardNumber: Int) : BackupAction() - object PrepareToWritePrimaryCard : BackupAction() object WritePrimaryCard : BackupAction() + data class PrepareToWriteBackupCard(val cardNumber: Int) : BackupAction() + data class WriteBackupCard(val cardNumber: Int) : BackupAction() object FinishBackup : BackupAction() object DiscardBackup : BackupAction() object DiscardSavedBackup : BackupAction() - object ResumeBackup : BackupAction() - + object ResumeFoundUnfinishedBackup : BackupAction() } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt index 1c41c5e27a..39d354a8b2 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt @@ -4,9 +4,9 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.common.CardFilter import com.tangem.common.CompletionResult import com.tangem.common.card.Card +import com.tangem.common.extensions.ifNotNull +import com.tangem.domain.common.SaltPayWorkaround import com.tangem.domain.common.ScanResponse -import com.tangem.domain.common.TapWorkarounds -import com.tangem.domain.common.TapWorkarounds.isSaltPay import com.tangem.domain.common.extensions.withMainContext import com.tangem.operations.backup.BackupService import com.tangem.tap.backupService @@ -19,6 +19,8 @@ import com.tangem.tap.domain.extensions.hasWallets import com.tangem.tap.domain.tokens.models.BlockchainNetwork import com.tangem.tap.features.demo.DemoHelper import com.tangem.tap.features.home.redux.HomeAction +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayAction +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayState import com.tangem.tap.features.wallet.redux.Artwork import com.tangem.tap.features.wallet.redux.WalletAction import com.tangem.tap.preferencesStorage @@ -28,6 +30,7 @@ import com.tangem.tap.tangemSdk import com.tangem.tap.tangemSdkManager import kotlinx.coroutines.launch import org.rekotlin.Action +import org.rekotlin.DispatchFunction import org.rekotlin.Middleware class OnboardingWalletMiddleware { @@ -39,35 +42,57 @@ class OnboardingWalletMiddleware { private val onboardingWalletMiddleware: Middleware = { dispatch, state -> { next -> { action -> - handleWalletAction(action) + handleWalletAction(action, state, dispatch) next(action) } } } -private fun handleWalletAction(action: Action) { +private fun handleWalletAction(action: Action, state: () -> AppState?, dispatch: DispatchFunction) { if (action !is OnboardingWalletAction) return + val globalState = store.state.globalState val onboardingManager = globalState.onboardingState.onboardingManager val scanResponse = onboardingManager?.scanResponse val card = scanResponse?.card - val walletState = store.state.onboardingWalletState + val onboardingWalletState = store.state.onboardingWalletState when (action) { OnboardingWalletAction.Init -> { - val action = when { + // if (walletState.onboardingSaltPayState?.isTest == true) { + // store.dispatch(BackupAction.FinishBackup) + // return + // } + + when { card == null -> { - OnboardingWalletAction.ProceedBackup() + // StartForUnfinishedBackup -> navigateTo(OnboardingWallet) -> OnboardingWalletAction.Init + store.dispatch(OnboardingWalletAction.ResumeBackup) + } + card.hasWallets() && card.backupStatus == Card.BackupStatus.NoBackup -> { + store.dispatch(OnboardingWalletAction.ResumeBackup) + } + card.hasWallets() && card.backupStatus?.isActive == true -> { + if (scanResponse.isSaltPay()) { + store.dispatch(OnboardingWalletAction.GetToSaltPayStep) + } + store.dispatch(BackupAction.FinishBackup) + } + else -> { + store.dispatch(OnboardingWalletAction.GetToCreateWalletStep) + } + } + } + is OnboardingWalletAction.StartSaltPayCardActivation -> { + // we need to do this because the standard activation happens in the OnboardingWalletAction.CreateWallet + // step which is omitted for SaltPay cards + if (onboardingWalletState.isSaltPay) { + ifNotNull(onboardingManager, scanResponse?.card?.cardId) { manager, cardId -> + manager.activationStarted(cardId) } - card.hasWallets() && card.backupStatus == Card.BackupStatus.NoBackup -> - OnboardingWalletAction.ProceedBackup(card.isSaltPay) - card.hasWallets() && card.backupStatus?.isActive == true -> - BackupAction.FinishBackup - else -> OnboardingWalletAction.GetToCreateWalletStep } - store.dispatch(action) } is OnboardingWalletAction.LoadArtwork -> { scope.launch { @@ -88,25 +113,24 @@ private fun handleWalletAction(action: Action) { when (result) { is CompletionResult.Success -> { //here we must use updated scanResponse after createWallet & derivation - val updatedResponse = - globalState.onboardingState.onboardingManager.scanResponse.copy( - card = result.data.card, - derivedKeys = result.data.derivedKeys, - primaryCard = result.data.primaryCard - ) + val updatedResponse = globalState.onboardingState.onboardingManager.scanResponse.copy( + card = result.data.card, + derivedKeys = result.data.derivedKeys, + primaryCard = result.data.primaryCard, + ) onboardingManager.scanResponse = updatedResponse val blockchainNetworks = listOf( BlockchainNetwork(Blockchain.Bitcoin, result.data.card), - BlockchainNetwork(Blockchain.Ethereum, result.data.card) + BlockchainNetwork(Blockchain.Ethereum, result.data.card), ) - store.dispatchOnMain( + store.dispatch( WalletAction.MultiWallet.SaveCurrencies( blockchainNetworks = blockchainNetworks, card = result.data.card, ), ) onboardingManager.activationStarted(updatedResponse.card.cardId) - store.dispatch(OnboardingWalletAction.ProceedBackup()) + store.dispatch(OnboardingWalletAction.ResumeBackup) } is CompletionResult.Failure -> { // do nothing @@ -118,6 +142,7 @@ private fun handleWalletAction(action: Action) { OnboardingWalletAction.FinishOnboarding -> { store.dispatch(GlobalAction.Onboarding.Stop) + //TODO: SaltPay: scanResponse if (scanResponse == null) { store.dispatch(NavigationAction.PopBackTo()) store.dispatch(HomeAction.ReadCard) @@ -128,15 +153,15 @@ private fun handleWalletAction(action: Action) { store.dispatchOnMain(NavigationAction.NavigateTo(AppScreen.Wallet)) } } - is OnboardingWalletAction.ProceedBackup -> { + is OnboardingWalletAction.ResumeBackup -> { val newAction = when (val backupState = backupService.currentState) { BackupService.State.FinalizingPrimaryCard -> BackupAction.PrepareToWritePrimaryCard is BackupService.State.FinalizingBackupCard -> BackupAction.PrepareToWriteBackupCard(backupState.index) else -> { - if (walletState.backupState.backupStep == BackupStep.InitBackup || - walletState.backupState.backupStep == BackupStep.Finished + if (onboardingWalletState.backupState.backupStep == BackupStep.InitBackup || + onboardingWalletState.backupState.backupStep == BackupStep.Finished ) { - BackupAction.IntroduceBackup(action.isSaltPay) + BackupAction.IntroduceBackup } else { null } @@ -145,7 +170,7 @@ private fun handleWalletAction(action: Action) { newAction?.let { store.dispatch(it) } } OnboardingWalletAction.OnBackPressed -> { - when (walletState.backupState.backupStep) { + when (onboardingWalletState.backupState.backupStep) { BackupStep.InitBackup, BackupStep.Finished -> store.dispatch(NavigationAction.PopBackTo()) BackupStep.ScanOriginCard, BackupStep.AddBackupCards, BackupStep.EnterAccessCode, BackupStep.ReenterAccessCode, BackupStep.SetAccessCode, BackupStep.WritePrimaryCard, @@ -167,7 +192,7 @@ private fun updateScanResponseAfterBackup( val cardsCount = backupState.backupCardsNumber scanResponse.card.copy( backupStatus = Card.BackupStatus.Active(cardCount = cardsCount), - isAccessCodeSet = true + isAccessCodeSet = true, ) } else { scanResponse.card @@ -188,13 +213,11 @@ class BackupMiddleware { private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) { if (DemoHelper.tryHandle(appState, action)) return + val globalState = appState()?.globalState ?: return + val onboardingWalletState = appState()?.onboardingWalletState ?: return - val backupState = store.state.onboardingWalletState.backupState - - val globalState = store.state.globalState - val onboardingManager = globalState.onboardingState.onboardingManager - - val scanResponse = onboardingManager?.scanResponse + val backupState = onboardingWalletState.backupState + val scanResponse = globalState.onboardingState.onboardingManager?.scanResponse val card = scanResponse?.card when (action) { @@ -221,8 +244,10 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) } is BackupAction.AddBackupCard -> { backupService.skipCompatibilityChecks = true - tangemSdk.config.filter.cardIdFilter = - CardFilter.Companion.ItemFilter.Allow(TapWorkarounds.saltPayTangemCardIds.toSet()) + tangemSdk.config.filter.cardIdFilter = CardFilter.Companion.CardIdFilter.Allow( + items = SaltPayWorkaround.tangemWalletCardIds.toSet(), + ranges = SaltPayWorkaround.tangemWalletCardIdRanges, + ) backupService.addBackupCard { result -> backupService.skipCompatibilityChecks = false @@ -237,20 +262,15 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) } } is BackupAction.FinishAddingBackupCards -> { - if (backupService.addedBackupCardsCount == 1) { - store.dispatchOnMain(GlobalAction.ShowDialog(BackupDialog.AddMoreBackupCards)) - } - if (backupService.addedBackupCardsCount == 2) { + if (backupService.addedBackupCardsCount == backupState.maxBackupCards) { store.dispatchOnMain(BackupAction.ShowAccessCodeInfoScreen) + } else { + store.dispatchOnMain(GlobalAction.ShowDialog(BackupDialog.AddMoreBackupCards)) } } is BackupAction.CheckAccessCode -> { if (action.accessCode.length < 4) { - store.dispatch( - BackupAction.SetAccessCodeError( - AccessCodeError.CodeTooShort - ) - ) + store.dispatch(BackupAction.SetAccessCodeError(AccessCodeError.CodeTooShort)) } else { store.dispatch(BackupAction.SetAccessCodeError(null)) store.dispatch(BackupAction.SaveFirstAccessCode(action.accessCode)) @@ -262,20 +282,14 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) backupService.setAccessCode(action.accessCodeConfirmation) store.dispatch(BackupAction.PrepareToWritePrimaryCard) } else { - store.dispatch( - BackupAction.SetAccessCodeError( - AccessCodeError.CodesDoNotMatch - ) - ) + store.dispatch(BackupAction.SetAccessCodeError(AccessCodeError.CodesDoNotMatch)) } } is BackupAction.WritePrimaryCard -> { backupService.proceedBackup { result -> when (result) { is CompletionResult.Success -> { - store.dispatchOnMain( - BackupAction.PrepareToWriteBackupCard(1) - ) + store.dispatchOnMain(BackupAction.PrepareToWriteBackupCard(1)) } is CompletionResult.Failure -> { } @@ -287,15 +301,13 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) when (result) { is CompletionResult.Success -> { if (backupService.currentState == BackupService.State.Finished) { + initSaltPayOnBackupFinishedIfNeeded(scanResponse, onboardingWalletState) store.dispatchOnMain(BackupAction.FinishBackup) } else { - store.dispatchOnMain( - BackupAction.PrepareToWriteBackupCard(action.cardNumber + 1) - ) + store.dispatchOnMain(BackupAction.PrepareToWriteBackupCard(action.cardNumber + 1)) } } is CompletionResult.Failure -> { - } } } @@ -321,13 +333,31 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) store.dispatch(GlobalAction.ShowDialog(BackupDialog.UnfinishedBackupFound)) } } - is BackupAction.ResumeBackup -> { - store.dispatch(GlobalAction.Onboarding.Start(null, true)) - store.dispatch(OnboardingWalletAction.ProceedBackup()) - store.dispatch(NavigationAction.NavigateTo(AppScreen.OnboardingWallet)) + is BackupAction.ResumeFoundUnfinishedBackup -> { + // val isSaltPay = backupService.primaryBatchId?.let { SaltPayWorkaround.isVisaBatchId(it) } ?: false + // store.dispatch(GlobalAction.Onboarding.StartForUnfinishedBackup(isSaltPay)) + // store.dispatch(NavigationAction.NavigateTo(AppScreen.OnboardingWallet)) } is BackupAction.DismissBackup -> { + if (onboardingWalletState.isSaltPay) throw UnsupportedOperationException() store.dispatch(BackupAction.FinishBackup) } } +} + +/** + * SaltPay state maybe not initialized if backup resumed from ResumeFoundUnfinishedBackup action + */ +private fun initSaltPayOnBackupFinishedIfNeeded( + scanResponse: ScanResponse?, + onboardingWalletState: OnboardingWalletState, +) { + if (onboardingWalletState.isSaltPay && onboardingWalletState.onboardingSaltPayState == null) { + //TODO: SaltPay: scanResponse + if (scanResponse == null) throw IllegalArgumentException() + + val (manager, config) = OnboardingSaltPayState.initDependency(scanResponse) + store.dispatchOnMain(OnboardingSaltPayAction.Init.SetDependencies(manager, config)) + store.dispatchOnMain(OnboardingSaltPayAction.Update) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletReducer.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletReducer.kt index 13b86b3e08..0f79ba3da0 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletReducer.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletReducer.kt @@ -3,12 +3,13 @@ package com.tangem.tap.features.onboarding.products.wallet.redux import com.tangem.tap.backupService import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.global.GlobalAction +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayAction +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayReducer import org.rekotlin.Action class OnboardingWalletReducer { companion object { - fun reduce(action: Action, state: AppState): OnboardingWalletState = - internalReduce(action, state) + fun reduce(action: Action, state: AppState): OnboardingWalletState = internalReduce(action, state) } } @@ -16,13 +17,17 @@ private fun internalReduce(action: Action, appState: AppState): OnboardingWallet val state = appState.onboardingWalletState return when (action) { - is BackupAction -> state.copy(backupState = BackupReducer.reduce(action, state.backupState)) - is GlobalAction.Onboarding.Start -> OnboardingWalletState( - backupState = state.backupState.copy(canSkipBackup = action.fromHomeScreen) + is GlobalAction.Onboarding -> ReducerForGlobalAction.reduce(action, state) + is OnboardingSaltPayAction -> OnboardingSaltPayReducer.reduce(action, state) + is BackupAction -> state.copy(backupState = BackupReducer.reduce(action, state.backupState, state.isSaltPay)) + + is OnboardingWalletAction.GetToCreateWalletStep -> OnboardingWalletState( + step = OnboardingWalletStep.CreateWallet, ) - is OnboardingWalletAction.GetToCreateWalletStep -> - OnboardingWalletState(step = OnboardingWalletStep.CreateWallet) - is OnboardingWalletAction.ProceedBackup -> state.copy(step = OnboardingWalletStep.Backup) + is OnboardingWalletAction.GetToSaltPayStep -> state.copy( + step = OnboardingWalletStep.SaltPay, + ) + is OnboardingWalletAction.ResumeBackup -> state.copy(step = OnboardingWalletStep.Backup) is OnboardingWalletAction.SetArtworkUrl -> { state.copy(cardArtworkUrl = action.artworkUrl) } @@ -31,18 +36,45 @@ private fun internalReduce(action: Action, appState: AppState): OnboardingWallet } } +private class ReducerForGlobalAction { + companion object { + fun reduce(action: GlobalAction.Onboarding, state: OnboardingWalletState): OnboardingWalletState = when (action) { + is GlobalAction.Onboarding.Start -> { + OnboardingWalletState( + backupState = state.backupState.copy( + maxBackupCards = if (action.scanResponse.isSaltPay()) 1 else 2, + canSkipBackup = if (action.scanResponse.isSaltPay()) false else action.canSkipBackup, + ), + isSaltPay = action.scanResponse.isSaltPay() + ) + } + is GlobalAction.Onboarding.StartForUnfinishedBackup -> { + OnboardingWalletState( + backupState = state.backupState.copy( + maxBackupCards = if (action.isSaltPayBackup) 1 else 2, + canSkipBackup = false, + ), + isSaltPay = action.isSaltPayBackup, + ) + } + else -> state + } + } +} -class BackupReducer { +private class BackupReducer { companion object { fun reduce( - action: BackupAction, state: BackupState, + action: BackupAction, + state: BackupState, + isSaltPay: Boolean, ): BackupState { return when (action) { is BackupAction.IntroduceBackup -> BackupState( backupStep = BackupStep.InitBackup, - canSkipBackup = state.canSkipBackup, - isSaltPay = action.isSaltPay, + maxBackupCards = if (isSaltPay) 1 else 2, + canSkipBackup = !isSaltPay, ) BackupAction.StartAddingPrimaryCard -> state.copy(backupStep = BackupStep.ScanOriginCard) BackupAction.StartAddingBackupCards -> { @@ -51,9 +83,6 @@ class BackupReducer { BackupAction.AddBackupCard.Success -> { state.copy(backupCardsNumber = state.backupCardsNumber + 1) } - BackupAction.FinishAddingBackupCards -> { - state - } BackupAction.ShowAccessCodeInfoScreen -> { state.copy(backupStep = BackupStep.SetAccessCode) } @@ -63,13 +92,11 @@ class BackupReducer { is BackupAction.SaveFirstAccessCode -> { state.copy( backupStep = BackupStep.ReenterAccessCode, - accessCode = action.accessCode + accessCode = action.accessCode, ) } is BackupAction.SetAccessCodeError -> { - state.copy( - accessCodeError = action.error - ) + state.copy(accessCodeError = action.error) } is BackupAction.PrepareToWritePrimaryCard -> { if (state.primaryCardId == null) { @@ -77,12 +104,10 @@ class BackupReducer { primaryCardId = backupService.primaryCardId, backupCardIds = backupService.backupCardIds, backupCardsNumber = backupService.backupCardIds.size, - backupStep = BackupStep.WritePrimaryCard + backupStep = BackupStep.WritePrimaryCard, ) } else { - state.copy( - backupStep = BackupStep.WritePrimaryCard - ) + state.copy(backupStep = BackupStep.WritePrimaryCard) } } is BackupAction.PrepareToWriteBackupCard -> { @@ -91,43 +116,21 @@ class BackupReducer { primaryCardId = backupService.primaryCardId, backupCardIds = backupService.backupCardIds, backupCardsNumber = backupService.backupCardIds.size, - backupStep = BackupStep.WriteBackupCard(action.cardNumber) + backupStep = BackupStep.WriteBackupCard(action.cardNumber), ) } else { - state.copy( - backupStep = BackupStep.WriteBackupCard(action.cardNumber) - ) + state.copy(backupStep = BackupStep.WriteBackupCard(action.cardNumber)) } } - is BackupAction.FinishBackup -> { - state.copy( - backupStep = BackupStep.Finished - ) + state.copy(backupStep = BackupStep.Finished) } - BackupAction.OnAccessCodeDialogClosed -> { state.copy(backupStep = BackupStep.AddBackupCards) } BackupAction.DiscardBackup -> BackupState() - - BackupAction.WritePrimaryCard -> state - is BackupAction.WriteBackupCard -> state - is BackupAction.SaveAccessCodeConfirmation -> state - BackupAction.ScanPrimaryCard -> state - BackupAction.ShowEnterAccessCodeScreen -> state - BackupAction.ShowReenterAccessCodeScreen -> state - BackupAction.AddBackupCard -> state - BackupAction.DismissBackup -> state - is BackupAction.LoadBackupCardArtwork -> state - is BackupAction.CheckAccessCode -> state - BackupAction.DetermineBackupStep -> state - BackupAction.CheckForUnfinishedBackup -> state - BackupAction.ResumeBackup -> state - BackupAction.DiscardSavedBackup -> state - BackupAction.StartBackup -> state + else -> state } - } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletState.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletState.kt index 05a307b5b9..c83c9220a0 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletState.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletState.kt @@ -2,6 +2,8 @@ package com.tangem.tap.features.onboarding.products.wallet.redux import android.graphics.Bitmap import com.tangem.tap.common.redux.StateDialog +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayState +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.SaltPayRegistrationStep import org.rekotlin.StateType /** @@ -10,15 +12,20 @@ import org.rekotlin.StateType data class OnboardingWalletState( val step: OnboardingWalletStep = OnboardingWalletStep.None, val backupState: BackupState = BackupState(), + val onboardingSaltPayState: OnboardingSaltPayState? = null, + val isSaltPay: Boolean = false, val cardArtworkUrl: String? = null, val showConfetti: Boolean = false, ) : StateType { + fun getMaxProgress(): Int = when { + isSaltPay -> 12 + else -> 6 + } + fun getProgressStep(): Int { return when { - step == OnboardingWalletStep.CreateWallet -> { - 1 - } + step == OnboardingWalletStep.CreateWallet -> 1 step == OnboardingWalletStep.Backup -> { when (backupState.backupStep) { BackupStep.InitBackup -> 2 @@ -29,30 +36,41 @@ data class OnboardingWalletState( BackupStep.ReenterAccessCode -> 4 BackupStep.SetAccessCode -> 4 BackupStep.WritePrimaryCard, is BackupStep.WriteBackupCard -> 5 - BackupStep.Finished -> 6 + BackupStep.Finished -> getMaxProgress() } } - step == OnboardingWalletStep.Done -> 6 + step == OnboardingWalletStep.SaltPay && isSaltPay -> { + when (onboardingSaltPayState!!.step) { + SaltPayRegistrationStep.NoGas -> 1 + SaltPayRegistrationStep.NeedPin -> 7 + SaltPayRegistrationStep.CardRegistration -> 8 + SaltPayRegistrationStep.Kyc -> 9 + SaltPayRegistrationStep.KycStart -> 10 + SaltPayRegistrationStep.KycWaiting -> 11 + SaltPayRegistrationStep.Finished -> getMaxProgress() + } + } + step == OnboardingWalletStep.Done -> getMaxProgress() else -> 1 } } } enum class OnboardingWalletStep { - None, CreateWallet, Backup, Done + None, CreateWallet, Backup, SaltPay, Done } data class BackupState( val primaryCardId: String? = null, val backupCardsNumber: Int = 0, val backupCardIds: List = emptyList(), + @Transient val backupCardsArtworks: Map = emptyMap(), val accessCode: String = "", val accessCodeError: AccessCodeError? = null, val backupStep: BackupStep = BackupStep.InitBackup, val maxBackupCards: Int = 2, val canSkipBackup: Boolean = true, - val isSaltPay: Boolean = false, ) enum class AccessCodeError { @@ -73,7 +91,7 @@ sealed class BackupStep { object Finished : BackupStep() } -sealed class BackupDialog: StateDialog { +sealed class BackupDialog : StateDialog { object AddMoreBackupCards : StateDialog object BackupInProgress : StateDialog object UnfinishedBackupFound : StateDialog diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/GnosisRegistrator.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/GnosisRegistrator.kt new file mode 100644 index 0000000000..5937e34621 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/GnosisRegistrator.kt @@ -0,0 +1,164 @@ +package com.tangem.tap.features.onboarding.products.wallet.saltPay + +import com.tangem.blockchain.blockchains.ethereum.CompiledEthereumTransaction +import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +import com.tangem.blockchain.blockchains.ethereum.SignedEthereumTransaction +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.blockchain.common.WalletManager +import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.extensions.SimpleResult +import com.tangem.blockchain.extensions.successOr +import com.tangem.common.extensions.guard +import com.tangem.domain.common.extensions.successOr +import com.tangem.tap.common.extensions.isPositive +import com.tangem.tap.common.extensions.safeUpdate +import com.tangem.tap.common.extensions.stub +import com.tangem.tap.domain.getFirstToken +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import java.math.BigDecimal +import java.math.BigInteger +import java.util.concurrent.atomic.AtomicLong + +/** +[REDACTED_AUTHOR] + */ +class GnosisRegistrator( + val walletManager: EthereumWalletManager, +) { + + private val walletAddress = walletManager.wallet.address + private val token = walletManager.wallet.getFirstToken().guard { + throw NullPointerException("WalletManager for GnosisRegistrator must contain token") + } + + private val otpProcessorContractAddress: String = when (walletManager.wallet.blockchain) { + Blockchain.SaltPay -> "0x3B4397C817A26521Df8bD01a949AFDE2251d91C2" + Blockchain.SaltPayTestnet -> "0x710BF23486b549836509a08c184cE0188830f197" + else -> throw IllegalArgumentException("GnosisRegistrator supports only the SaltPay blockchain") + } + + private val atomicNonce = AtomicLong() + + suspend fun checkHasGas(): Result { + val wallet = walletManager.safeUpdate().successOr { + return Result.Failure(BlockchainSdkError.WrappedThrowable(it.error)) + } + + val hasGas = wallet.amounts[AmountType.Coin]?.value?.isPositive() ?: false + return Result.Success(hasGas) + } + + suspend fun prepareBeforeMakingTxs(): Result { + walletManager.safeUpdate().successOr { + return Result.Failure(BlockchainSdkError.WrappedThrowable(it.error)) + } + atomicNonce.set(walletManager.txCount) + + return Result.Success(Unit) + } + + suspend fun sendTransactions(transactions: List): Result> { + val results = transactions.map { + coroutineScope { async { walletManager.sendRaw(it.compiledTransaction, it.signature) } } + }.awaitAll() + + val errors = results.filterIsInstance() + return if (errors.isEmpty()) { + Result.Success(listOf()) + } else { + Result.Failure(errors[0].error) + } + } + + suspend fun makeSetSpendLimitTx(value: BigDecimal): Result { + val limitAmount = Amount(walletManager.cardTokens.first(), value) + val feeAmount = walletManager.getFeeToSetSpendLimit(otpProcessorContractAddress, limitAmount) + .extractFeeAmount().successOr { return it } + + val compiledEthereumTransaction = walletManager.transactionBuilder.buildSetSpendLimitToSign( + processorContractAddress = otpProcessorContractAddress, + cardAddress = walletAddress, + amount = limitAmount, + transactionFee = feeAmount, + gasLimit = walletManager.gasLimitToSetSpendLimit, + nonce = atomicNonce.getAndIncrement().toBigInteger(), + ) ?: return Result.Failure(BlockchainSdkError.CustomError("Can't create the 'setSpendLimit' transaction")) + + return Result.Success(compiledEthereumTransaction) + } + + suspend fun makeInitOtpTx(rootOTP: ByteArray, rootOTPCounter: Int): Result { + val feeAmount = walletManager.getFeeToInitOTP(otpProcessorContractAddress, rootOTP, rootOTPCounter) + .extractFeeAmount().successOr { return it } + + val compiledEthereumTransaction = walletManager.transactionBuilder.buildInitOTPToSign( + processorContractAddress = otpProcessorContractAddress, + cardAddress = walletAddress, + otp = rootOTP, + otpCounter = rootOTPCounter, + transactionFee = feeAmount, + gasLimit = walletManager.gasLimitToInitOTP, + nonce = atomicNonce.getAndIncrement().toBigInteger(), + ) ?: return Result.Failure(BlockchainSdkError.CustomError("Can't create the 'initOtp' transaction")) + + return Result.Success(compiledEthereumTransaction) + } + + suspend fun makeSetWalletTx(): Result { + val feeAmount = walletManager.getFeeToSetWallet(otpProcessorContractAddress) + .extractFeeAmount().successOr { return it } + + val compiledEthereumTransaction = walletManager.transactionBuilder.buildSetWalletToSign( + processorContractAddress = otpProcessorContractAddress, + cardAddress = walletAddress, + transactionFee = feeAmount, + gasLimit = walletManager.gasLimitToSetWallet, + nonce = atomicNonce.getAndIncrement().toBigInteger(), + ) ?: return Result.Failure(BlockchainSdkError.CustomError("Can't create the 'setWallet' transaction")) + + return Result.Success(compiledEthereumTransaction) + } + + suspend fun makeApprovalTx(value: BigDecimal): Result { + val approveAmount = Amount(token, value) + val feeAmount = walletManager.getFeeToApprove(approveAmount, otpProcessorContractAddress) + .extractFeeAmount().successOr { return it } + + val compiledEthereumTransaction = walletManager.transactionBuilder.buildApproveToSign( + transactionData = walletManager.createTransaction( + amount = approveAmount, + fee = feeAmount, + destination = otpProcessorContractAddress, + ), + nonce = atomicNonce.getAndIncrement().toBigInteger(), + gasLimit = walletManager.gasLimitToApprove, + ) ?: return Result.Failure(BlockchainSdkError.CustomError("Can't create the 'approval' transaction")) + + return Result.Success(compiledEthereumTransaction) + } + + private fun Result>.extractFeeAmount(): Result { + return when (this) { + is Result.Success -> { + if (this.data.size != 3) Result.Failure(BlockchainSdkError.FailedToLoadFee) + else Result.Success(this.data[1]) + } + is Result.Failure -> this + } + } + + private fun Long?.toBigInteger(): BigInteger? = this?.let { BigInteger.valueOf(it) } + + private fun Long.toBigInteger(): BigInteger = BigInteger.valueOf(this) + + companion object { + fun stub(): GnosisRegistrator { + return GnosisRegistrator(WalletManager.stub()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/PinFilters.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/PinFilters.kt new file mode 100644 index 0000000000..15a4137ec0 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/PinFilters.kt @@ -0,0 +1,18 @@ +package com.tangem.tap.features.onboarding.products.wallet.saltPay + +import com.tangem.common.Filter + +/** +[REDACTED_AUTHOR] + */ +abstract class PinFilter : Filter + +class AllSymbolsTheSameFilter : PinFilter() { + override fun filter(value: String): Boolean { + if (value.isEmpty()) return false + + val array = value.toCharArray() + val firstSymbol = array[0] + return array.all { it == firstSymbol } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/SaltPayConfig.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/SaltPayConfig.kt new file mode 100644 index 0000000000..d853819a57 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/SaltPayConfig.kt @@ -0,0 +1,27 @@ +package com.tangem.tap.features.onboarding.products.wallet.saltPay + +import com.tangem.tap.common.zendesk.ZendeskConfig + +/** +[REDACTED_AUTHOR] + */ +data class SaltPayConfig( + val zendesk: ZendeskConfig, + val kycProvider: KYCProvider, +) { + companion object { + fun stub(): SaltPayConfig { + return SaltPayConfig( + zendesk = ZendeskConfig("", "", "", "", ""), + kycProvider = KYCProvider("", "", "", ""), + ) + } + } +} + +data class KYCProvider( + val baseUrl: String, + val externalIdParameterKey: String, + val sidParameterKey: String, + val sidValue: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/SaltPayRegistrationManager.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/SaltPayRegistrationManager.kt new file mode 100644 index 0000000000..b14552983d --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/SaltPayRegistrationManager.kt @@ -0,0 +1,156 @@ +package com.tangem.tap.features.onboarding.products.wallet.saltPay + +import android.net.Uri +import com.tangem.blockchain.blockchains.ethereum.SignedEthereumTransaction +import com.tangem.common.services.Result +import com.tangem.domain.common.extensions.successOr +import com.tangem.network.api.paymentology.AttestationResponse +import com.tangem.network.api.paymentology.PaymentologyApiService +import com.tangem.network.api.paymentology.RegisterKYCRequest +import com.tangem.network.api.paymentology.RegisterWalletRequest +import com.tangem.network.api.paymentology.RegisterWalletResponse +import com.tangem.network.api.paymentology.RegistrationResponse +import com.tangem.operations.attestation.AttestWalletKeyResponse +import com.tangem.tap.domain.tokens.UserWalletId +import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayRegistrationError +import com.tangem.tap.persistence.SaltPayRegistrationStorage +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +class SaltPayRegistrationManager( + val cardId: String, + val cardPublicKey: ByteArray, + val walletPublicKey: ByteArray, + private val kycProvider: KYCProvider, + private val paymentologyService: PaymentologyApiService, + private val gnosisRegistrator: GnosisRegistrator, + private val registrationStorage: SaltPayRegistrationStorage, +) { + val kycUrlProvider = KYCUrlProvider(walletPublicKey, kycProvider) + + private val approvalValue: BigDecimal = BigDecimal.ONE + private val spendLimitValue: BigDecimal = BigDecimal("100") + + fun transactionIsSent(): Boolean { + return registrationStorage.data.transactionsSent + } + + suspend fun checkHasGas(): Result { + return when (val hasGasResult = gnosisRegistrator.checkHasGas()) { + is com.tangem.blockchain.extensions.Result.Success -> if (hasGasResult.data) { + Result.Success(Unit) + } else { + Result.Failure(SaltPayRegistrationError.NoGas) + } + is com.tangem.blockchain.extensions.Result.Failure -> Result.Failure(SaltPayRegistrationError.NoGas) + } + } + + suspend fun registerKYC(): Result { + return when (val result = paymentologyService.registerKYC(makeRegisterKYCRequest())) { + is Result.Success -> Result.Success(Unit) + is Result.Failure -> result + } + } + + suspend fun checkRegistration(): Result { + val registrationResponse = paymentologyService.checkRegistration(cardId, cardPublicKey).successOr { return it } + + return try { + if (!registrationResponse.success || registrationResponse.results.isEmpty()) { + throw SaltPayRegistrationError.EmptyResponse + } + Result.Success(registrationResponse.results[0]) + } catch (ex: SaltPayRegistrationError) { + Result.Failure(ex) + } + } + + suspend fun requestAttestationChallenge(): Result { + return paymentologyService.requestAttestationChallenge(cardId, cardPublicKey) + } + + suspend fun sendTransactions( + signedTransactions: List, + ): com.tangem.blockchain.extensions.Result> { + return gnosisRegistrator.sendTransactions(signedTransactions) + } + + suspend fun registerWallet( + attestResponse: AttestWalletKeyResponse, + pinCode: String, + ): Result { + val request = RegisterWalletRequest( + cardId = cardId, + publicKey = cardPublicKey, + walletPublicKey = walletPublicKey, + walletSalt = attestResponse.salt, + walletSignature = attestResponse.walletSignature, + cardSalt = attestResponse.publicKeySalt ?: byteArrayOf(), + cardSignature = attestResponse.cardSignature ?: byteArrayOf(), + pin = pinCode, + ) + + val result = paymentologyService.registerWallet(request) + + registrationStorage.data = registrationStorage.data.copy( + transactionsSent = result is Result.Success, + ) + + return result + } + + fun makeRegistrationTask(challenge: ByteArray): SaltPayRegistrationTask { + return SaltPayRegistrationTask( + gnosisRegistrator = gnosisRegistrator, + challenge = challenge, + walletPublicKey = walletPublicKey, + approvalValue = approvalValue, + spendLimitValue = spendLimitValue, + ) + } + + private fun makeRegisterKYCRequest(): RegisterKYCRequest = RegisterKYCRequest( + cardId = cardId, + publicKey = cardPublicKey, + kycProvider = "UTORG", + kycRefId = kycUrlProvider.kycRefId, + ) + + companion object { + fun stub(): SaltPayRegistrationManager = SaltPayRegistrationManager( + kycProvider = SaltPayConfig.stub().kycProvider, + paymentologyService = PaymentologyApiService.stub(), + gnosisRegistrator = GnosisRegistrator.stub(), + registrationStorage = SaltPayRegistrationStorage.stub(), + cardId = "", + cardPublicKey = byteArrayOf(), + walletPublicKey = byteArrayOf(), + ) + } +} + +class KYCUrlProvider( + walletPublicKey: ByteArray, + kycProvider: KYCProvider, +) { + + val kycRefId = UserWalletId(walletPublicKey).stringValue + + val requestUrl: String = makeWebRequestUrl(kycProvider) + + val doneUrl = "https://success.tangem.com/" + + private fun makeWebRequestUrl(kycProvider: KYCProvider): String { + val baseUri = Uri.parse(kycProvider.baseUrl) + return Uri.Builder() + .scheme(baseUri.scheme) + .authority(baseUri.authority) + .encodedPath(baseUri.path) + .appendQueryParameter(kycProvider.sidParameterKey, kycProvider.sidValue) + .appendQueryParameter(kycProvider.externalIdParameterKey, kycRefId) + .build().toString() + } +} diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/SaltPayRegistrationTask.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/SaltPayRegistrationTask.kt new file mode 100644 index 0000000000..11a0a30404 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/SaltPayRegistrationTask.kt @@ -0,0 +1,170 @@ +package com.tangem.tap.features.onboarding.products.wallet.saltPay + +import com.tangem.blockchain.blockchains.ethereum.CompiledEthereumTransaction +import com.tangem.blockchain.blockchains.ethereum.SignedEthereumTransaction +import com.tangem.blockchain.extensions.successOr +import com.tangem.common.CompletionResult +import com.tangem.common.core.CardSession +import com.tangem.common.core.CardSessionRunnable +import com.tangem.common.core.CompletionCallback +import com.tangem.common.core.TangemSdkError +import com.tangem.common.extensions.guard +import com.tangem.operations.CommandResponse +import com.tangem.operations.GenerateOTPCommand +import com.tangem.operations.GenerateOTPResponse +import com.tangem.operations.attestation.AttestWalletKeyCommand +import com.tangem.operations.attestation.AttestWalletKeyResponse +import com.tangem.operations.sign.SignHashesCommand +import com.tangem.tap.scope +import kotlinx.coroutines.launch +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +data class RegistrationTaskResponse( + val signedTransactions: List, + val attestResponse: AttestWalletKeyResponse, +) : CommandResponse + +class SaltPayRegistrationTask( + private var gnosisRegistrator: GnosisRegistrator, + private var challenge: ByteArray, + private val walletPublicKey: ByteArray, + private val approvalValue: BigDecimal, + private val spendLimitValue: BigDecimal, +) : CardSessionRunnable { + + private var generateOTPResponse: GenerateOTPResponse? = null + private var attestWalletResponse: AttestWalletKeyResponse? = null + private var signedTransactions: List = listOf() + + override fun run(session: CardSession, callback: CompletionCallback) { + generateOTP(session, callback) + } + + private fun generateOTP(session: CardSession, callback: CompletionCallback) { + GenerateOTPCommand().run(session) { result -> + when (result) { + is CompletionResult.Success -> { + generateOTPResponse = result.data + attestWallet(session, callback) + } + is CompletionResult.Failure -> callback(CompletionResult.Failure(result.error)) + } + } + } + + private fun attestWallet( + session: CardSession, + callback: CompletionCallback, + ) { + val card = session.environment.card.guard { + callback(CompletionResult.Failure(TangemSdkError.MissingPreflightRead())) + return + } + + val walletPublicKey = card.wallets.firstOrNull()?.publicKey + if (walletPublicKey == null || !walletPublicKey.contentEquals(this.walletPublicKey)) { + callback(CompletionResult.Failure(TangemSdkError.WalletNotFound())) + return + } + + val attestWalletCommand = AttestWalletKeyCommand( + publicKey = walletPublicKey, + challenge = challenge, + confirmationMode = AttestWalletKeyCommand.ConfirmationMode.Dynamic, + ) + attestWalletCommand.run(session) { result -> + when (result) { + is CompletionResult.Success -> { + attestWalletResponse = result.data + scope.launch { prepareTransactions(session, callback) } + } + is CompletionResult.Failure -> callback(CompletionResult.Failure(result.error)) + } + } + } + + private suspend fun prepareTransactions( + session: CardSession, + callback: CompletionCallback, + ) { + val generateOTPResponse = this.generateOTPResponse.guard { + callback(CompletionResult.Failure(TangemSdkError.UnknownError())) + return + } + + gnosisRegistrator.prepareBeforeMakingTxs().successOr { + callback(CompletionResult.Failure(it.error)) + return + } + + val approvalTx = gnosisRegistrator.makeApprovalTx(approvalValue).successOr { + callback(CompletionResult.Failure(it.error)) + return + } + val setWalletTx = gnosisRegistrator.makeSetWalletTx().successOr { + callback(CompletionResult.Failure(it.error)) + return + } + val initOtpTx = gnosisRegistrator.makeInitOtpTx( + generateOTPResponse.rootOTP, + generateOTPResponse.rootOTPCounter, + ).successOr { + callback(CompletionResult.Failure(it.error)) + return + } + + val setSpendLimitTx = gnosisRegistrator.makeSetSpendLimitTx(spendLimitValue).successOr { + callback(CompletionResult.Failure(it.error)) + return + } + + signTransactions( + transactions = listOf( + approvalTx, + setWalletTx, + initOtpTx, + setSpendLimitTx, + ), + session = session, + callback = callback, + ) + } + + private fun signTransactions( + transactions: List, + session: CardSession, + callback: CompletionCallback, + ) { + val walletPublicKey = session.environment.card?.wallets?.firstOrNull()?.publicKey.guard { + callback(CompletionResult.Failure(TangemSdkError.WalletNotFound())) + return + } + + val hashes = transactions.map { it.hash }.toTypedArray() + val signHashesCommand = SignHashesCommand(hashes, walletPublicKey) + + signHashesCommand.run(session) { result -> + when (result) { + is CompletionResult.Success -> { + signedTransactions = transactions.zip(result.data.signatures).map { + SignedEthereumTransaction(it.first, it.second) + } + complete(callback) + } + is CompletionResult.Failure -> callback(CompletionResult.Failure(result.error)) + } + } + } + + private fun complete(callback: CompletionCallback) { + if (signedTransactions.isEmpty() || attestWalletResponse == null) { + callback(CompletionResult.Failure(TangemSdkError.UnknownError())) + } else { + val result = RegistrationTaskResponse(signedTransactions, attestWalletResponse!!) + callback(CompletionResult.Success(result)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayAction.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayAction.kt new file mode 100644 index 0000000000..30f0f25d71 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayAction.kt @@ -0,0 +1,34 @@ +package com.tangem.tap.features.onboarding.products.wallet.saltPay.redux + +import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayConfig +import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayRegistrationManager +import org.rekotlin.Action + +/** +[REDACTED_AUTHOR] + */ +sealed class OnboardingSaltPayAction : Action { + sealed class Init : OnboardingSaltPayAction() { + data class SetDependencies( + val registrationManager: SaltPayRegistrationManager, + val saltPayConfig: SaltPayConfig, + ) : Init() + + // only for tests + object DiscardBackupSteps : Init() + } + + data class SetIsBusy(val isBusy: Boolean) : OnboardingSaltPayAction() + data class SetAccessCode(val accessCode: ByteArray?) : OnboardingSaltPayAction() + + object Update : OnboardingSaltPayAction() + object RegisterCard : OnboardingSaltPayAction() + object KYCStart : OnboardingSaltPayAction() + object OnFinishKYC : OnboardingSaltPayAction() + + data class TrySetPin(val pin: String) : OnboardingSaltPayAction() + data class SetPin(val pin: String) : OnboardingSaltPayAction() + + // data class TryUpdateStep(val newStep: SaltPayRegistrationStep? = null) : OnboardingSaltPayAction() + data class SetStep(val newStep: SaltPayRegistrationStep? = null) : OnboardingSaltPayAction() +} diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayMiddleware.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayMiddleware.kt new file mode 100644 index 0000000000..1a85b11d76 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayMiddleware.kt @@ -0,0 +1,288 @@ +package com.tangem.tap.features.onboarding.products.wallet.saltPay.redux + +import com.tangem.Message +import com.tangem.blockchain.extensions.successOr +import com.tangem.common.Filter +import com.tangem.common.core.TangemSdkError +import com.tangem.common.extensions.guard +import com.tangem.common.services.Result +import com.tangem.domain.common.extensions.successOr +import com.tangem.domain.common.extensions.withMainContext +import com.tangem.network.api.paymentology.RegistrationResponse +import com.tangem.tap.common.extensions.dispatchDialogShow +import com.tangem.tap.common.extensions.dispatchErrorNotification +import com.tangem.tap.common.extensions.dispatchOnMain +import com.tangem.tap.common.redux.AppState +import com.tangem.tap.domain.TapError +import com.tangem.tap.features.demo.DemoHelper +import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletState +import com.tangem.tap.features.onboarding.products.wallet.saltPay.AllSymbolsTheSameFilter +import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayRegistrationManager +import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.SaltPayDialog +import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayRegistrationError +import com.tangem.tap.scope +import com.tangem.tap.store +import com.tangem.tap.tangemSdkManager +import com.tangem.wallet.R +import kotlinx.coroutines.launch +import org.rekotlin.Action +import org.rekotlin.Middleware +import timber.log.Timber + +/** +[REDACTED_AUTHOR] + */ +class OnboardingSaltPayMiddleware { + companion object { + val handler = onboardingSaltPayMiddleware + } +} + +private val onboardingSaltPayMiddleware: Middleware = { dispatch, state -> + { next -> + { action -> + handleOnboardingSaltPayAction(action, state) + next(action) + } + } +} + +private fun handleOnboardingSaltPayAction(anyAction: Action, appState: () -> AppState?) { + if (DemoHelper.tryHandle(appState, anyAction)) return + val action = anyAction as? OnboardingSaltPayAction ?: return + appState() ?: return + + fun getAppState(): AppState = appState()!! + fun getOnboardingWalletState(): OnboardingWalletState = getAppState().onboardingWalletState + fun getState(): OnboardingSaltPayState = getOnboardingWalletState().onboardingSaltPayState!! + + when (action) { + is OnboardingSaltPayAction.RegisterCard -> { + handleIsBusy = true + val state = getState() + + scope.launch { + val saltPayManager = state.saltPayManager + checkGasIfNeeded(state.saltPayManager, state.step).successOr { + onException(it.error) + return@launch + } + + val pinCode = state.pinCode.guard { + onError(SaltPayRegistrationError.NeedPin) + return@launch + } + + val attestationResponse = saltPayManager.requestAttestationChallenge().successOr { + onException(it.error) + return@launch + } + + if (!attestationResponse.success) { + onError(SaltPayRegistrationError.CardNotFound) + return@launch + } + + val registrationResponse = tangemSdkManager.runTaskAsync( + runnable = saltPayManager.makeRegistrationTask(attestationResponse.challenge!!), + cardId = saltPayManager.cardId, + initialMessage = Message( + header = null, + body = tangemSdkManager.getString(R.string.registration_task_alert_message), + ), + ).successOr { + onException(it.error as Exception) + return@launch + } + + saltPayManager.sendTransactions(registrationResponse.signedTransactions).successOr { + onError(SaltPayRegistrationError.FailedToSendTx) + return@launch + } + + saltPayManager.registerWallet(registrationResponse.attestResponse, pinCode).successOr { + onException(it.error) + return@launch + } + + withMainContext { + handleIsBusy = false + store.dispatch(OnboardingSaltPayAction.SetStep(SaltPayRegistrationStep.Kyc)) + } + } + } + is OnboardingSaltPayAction.KYCStart -> { + store.dispatch(OnboardingSaltPayAction.SetStep(SaltPayRegistrationStep.KycStart)) + } + is OnboardingSaltPayAction.OnFinishKYC -> { + scope.launch { + registerKYCIfNeeded(getState()).successOr { + onException(it.error) + } + } + } + is OnboardingSaltPayAction.TrySetPin -> { + try { + assertPinValid(action.pin, getState().pinLength) + store.dispatch(OnboardingSaltPayAction.SetPin(action.pin)) + store.dispatch(OnboardingSaltPayAction.SetStep(SaltPayRegistrationStep.CardRegistration)) + } catch (error: SaltPayRegistrationError) { + handleError = error + } + } + is OnboardingSaltPayAction.Update -> { + handleIsBusy = true + + val state = getState() + scope.launch { + checkGasIfNeeded(state.saltPayManager, state.step).successOr { + onException(it.error) + return@launch + } + + registerKYCIfNeeded(state).successOr { + onException(it.error) + return@launch + } + + val newStep = checkRegistration(state).successOr { + onException(it.error) + return@launch + } + + withMainContext { + handleIsBusy = false + store.dispatch(OnboardingSaltPayAction.SetStep(newStep)) + } + } + } + else -> { + /* do nothing, only reduce */ + } + } +} + +private var handleIsBusy: Boolean = false + set(value) { + field = value + store.dispatchOnMain(OnboardingSaltPayAction.SetIsBusy(value)) + } + +private suspend fun onError(error: SaltPayRegistrationError) { + withMainContext { + handleIsBusy = false + handleError = error + } +} + +private suspend fun onException(error: Throwable) { + withMainContext { + handleIsBusy = false + handleException = error + } +} + +private var handleException: Throwable = Throwable("Init") + set(value) { + field = value + when (value) { + is SaltPayRegistrationError -> handleError = value + else -> { + Timber.e(value) + when (value) { + is TangemSdkError -> { + // store.dispatchErrorNotification(TapError.CustomError(value.customMessage)) + } + else -> { + store.dispatchErrorNotification( + TapError.CustomError(value.message ?: "Unknown SaltPay exception"), + ) + } + } + } + } + } + +private var handleError: SaltPayRegistrationError = SaltPayRegistrationError.Unknown + set(value) { + field = value + when (value) { + SaltPayRegistrationError.NoGas -> { + store.dispatch(OnboardingSaltPayAction.SetStep(SaltPayRegistrationStep.NoGas)) + } + else -> { + store.dispatchDialogShow(SaltPayDialog.RegistrationError(value)) + } + } + } + +private suspend fun checkGasIfNeeded( + saltPayManager: SaltPayRegistrationManager, + step: SaltPayRegistrationStep, +): Result { + if (step == SaltPayRegistrationStep.KycStart || + step == SaltPayRegistrationStep.KycWaiting || + step == SaltPayRegistrationStep.Finished + ) { + return Result.Success(Unit) + } + + return saltPayManager.checkHasGas() +} + +private suspend fun registerKYCIfNeeded(state: OnboardingSaltPayState): Result { + if (state.step != SaltPayRegistrationStep.KycStart) { + return Result.Success(Unit) + } + + withMainContext { + store.dispatch(OnboardingSaltPayAction.SetStep(SaltPayRegistrationStep.KycWaiting)) + } + return state.saltPayManager.registerKYC() +} + +private suspend fun checkRegistration( + state: OnboardingSaltPayState, +): Result { + return try { + val registrationResponseItem = state.saltPayManager.checkRegistration().successOr { return it } + val step = registrationResponseItem.toSaltPayStep() + Result.Success(step) + } catch (ex: SaltPayRegistrationError) { + Result.Failure(ex) + } +} + +@Throws(SaltPayRegistrationError::class) +fun RegistrationResponse.Item.toSaltPayStep(): SaltPayRegistrationStep { + return when { + passed != true -> throw SaltPayRegistrationError.CardNotPassed + disabledByAdmin == true -> throw SaltPayRegistrationError.CardDisabled + + // go to success screen + active == true -> SaltPayRegistrationStep.Finished + + // pinSet is false, go toPin screen + pinSet == false -> SaltPayRegistrationStep.NeedPin + + // kycDate is set, go to kyc waiting screen + kycDate != null -> SaltPayRegistrationStep.KycWaiting + + else -> SaltPayRegistrationStep.KycStart + } +} + +@Throws(SaltPayRegistrationError::class) +private fun assertPinValid(pin: String, pinLength: Int) { + val array = pin.toCharArray() + + if (array.size < pinLength) throw SaltPayRegistrationError.WeakPin + + val filters: List> = listOf( + AllSymbolsTheSameFilter(), + ) + + if (filters.any { it.filter(pin) }) { + throw SaltPayRegistrationError.WeakPin + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayReducer.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayReducer.kt new file mode 100644 index 0000000000..ac9ed3c67b --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayReducer.kt @@ -0,0 +1,86 @@ +package com.tangem.tap.features.onboarding.products.wallet.saltPay.redux + +import com.tangem.tap.features.onboarding.products.wallet.redux.BackupStep +import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletState +import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletStep +import org.rekotlin.Action + +/** +[REDACTED_AUTHOR] + */ +class OnboardingSaltPayReducer { + companion object { + fun reduce(action: Action, state: OnboardingWalletState): OnboardingWalletState { + return internalReduce(action, state) + } + } +} + +private fun internalReduce(anyAction: Action, onboardingWalletState: OnboardingWalletState): OnboardingWalletState { + val action = anyAction as? OnboardingSaltPayAction ?: return onboardingWalletState + + if (onboardingWalletState.onboardingSaltPayState == null && action is OnboardingSaltPayAction.Init.SetDependencies) { + return onboardingWalletState.copy( + onboardingSaltPayState = OnboardingSaltPayState( + saltPayManager = action.registrationManager, + saltPayConfig = action.saltPayConfig, + ), + ) + } + + val oldState = onboardingWalletState.onboardingSaltPayState ?: return onboardingWalletState + + val newState = when (action) { + OnboardingSaltPayAction.Init.DiscardBackupSteps -> { + return onboardingWalletState.copy( + step = OnboardingWalletStep.SaltPay, + backupState = onboardingWalletState.backupState.copy( + backupStep = BackupStep.Finished, + maxBackupCards = 1, + canSkipBackup = false, + ), + onboardingSaltPayState = oldState.copy( + isTest = true, + step = SaltPayRegistrationStep.NeedPin, + ), + ) + } + is OnboardingSaltPayAction.SetAccessCode -> { + oldState.copy( + accessCode = action.accessCode, + ) + } + is OnboardingSaltPayAction.SetPin -> { + oldState.copy(pinCode = action.pin) + } + is OnboardingSaltPayAction.SetStep -> { + if (action.newStep == null) { + oldState + } else { + oldState.copy( + step = action.newStep, + ) + } + } + is OnboardingSaltPayAction.SetIsBusy -> { + oldState.copy( + isBusy = action.isBusy, + ) + } + else -> oldState + // is OnboardingSaltPayAction.Init.PrepareAndSetDependencies -> oldState + // OnboardingSaltPayAction.OnCreate -> oldState + // is OnboardingSaltPayAction.OnFinishKYC -> oldState + // is OnboardingSaltPayAction.Register -> oldState + // is OnboardingSaltPayAction.TrySetPin -> oldState + // is OnboardingSaltPayAction.Update -> oldState + // is OnboardingSaltPayAction.TryUpdateStep -> oldState + } + + return when (newState) { + oldState -> onboardingWalletState + else -> { + onboardingWalletState.copy(onboardingSaltPayState = newState) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayState.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayState.kt new file mode 100644 index 0000000000..dfa556bbd5 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/saltPay/redux/OnboardingSaltPayState.kt @@ -0,0 +1,102 @@ +package com.tangem.tap.features.onboarding.products.wallet.saltPay.redux + +import com.tangem.blockchain.common.WalletManagerFactory +import com.tangem.common.extensions.guard +import com.tangem.domain.common.ScanResponse +import com.tangem.network.api.paymentology.PaymentologyApiService +import com.tangem.tap.common.toggleWidget.WidgetState +import com.tangem.tap.domain.extensions.makeSaltPayWalletManager +import com.tangem.tap.features.onboarding.products.wallet.saltPay.GnosisRegistrator +import com.tangem.tap.features.onboarding.products.wallet.saltPay.KYCProvider +import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayConfig +import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayRegistrationManager +import com.tangem.tap.features.wallet.redux.ProgressState +import com.tangem.tap.persistence.SaltPayRegistrationStorage +import com.tangem.tap.preferencesStorage +import com.tangem.tap.store + +/** +[REDACTED_AUTHOR] + */ +data class OnboardingSaltPayState( + @Transient + val saltPayManager: SaltPayRegistrationManager = SaltPayRegistrationManager.stub(), + val saltPayConfig: SaltPayConfig = SaltPayConfig.stub(), + val pinCode: String? = null, + val accessCode: ByteArray? = null, + val step: SaltPayRegistrationStep = SaltPayRegistrationStep.NeedPin, + val saltPayCardArtworkUrl: String? = null, + val isBusy: Boolean = false, + val isTest: Boolean = false, +) { + + val mainButtonState: WidgetState + get() = if (isBusy) ProgressState.Loading else ProgressState.Done + + val pinLength: Int = 4 + + companion object { + fun initDependency(scanResponse: ScanResponse): Pair { + val globalState = store.state.globalState + val saltPayConfig = globalState.configManager?.config?.saltPayConfig.guard { + throw NullPointerException("SaltPayConfig is not initialized") + } + val gnosisRegistrator = makeGnosisRegistrator( + scanResponse = scanResponse, + wmFactory = globalState.tapWalletManager.walletManagerFactory, + ) + val registrationManager = makeSaltPayRegistrationManager( + scanResponse = scanResponse, + gnosisRegistrator = gnosisRegistrator, + paymentologyService = store.state.domainNetworks.paymentologyService, + registrationStorage = preferencesStorage.saltPayRegistrationStorage, + kycProvider = saltPayConfig.kycProvider, + ) + return registrationManager to saltPayConfig + } + + fun makeSaltPayRegistrationManager( + scanResponse: ScanResponse, + gnosisRegistrator: GnosisRegistrator, + paymentologyService: PaymentologyApiService, + registrationStorage: SaltPayRegistrationStorage, + kycProvider: KYCProvider, + ): SaltPayRegistrationManager { + if (!scanResponse.isSaltPay()) { + throw IllegalArgumentException("Can't initialize the OnboardingSaltPayMiddleware if card is not SalPay") + } + val saltPaySingleWallet = scanResponse.card.wallets.firstOrNull().guard { + throw NullPointerException("SaltPay card must have one wallet at least") + } + + return SaltPayRegistrationManager( + cardId = scanResponse.card.cardId, + cardPublicKey = scanResponse.card.cardPublicKey, + walletPublicKey = saltPaySingleWallet.publicKey, + kycProvider = kycProvider, + paymentologyService = paymentologyService, + gnosisRegistrator = gnosisRegistrator, + registrationStorage = registrationStorage, + ) + } + + fun makeGnosisRegistrator( + scanResponse: ScanResponse, + wmFactory: WalletManagerFactory, + ): GnosisRegistrator { + return GnosisRegistrator( + walletManager = wmFactory.makeSaltPayWalletManager(scanResponse), + ) + } + } +} + +enum class SaltPayRegistrationStep { + NoGas, + NeedPin, + CardRegistration, + Kyc, + KycStart, + KycWaiting, + Finished; +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/OnboardingWalletFragment.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/OnboardingWalletFragment.kt index 748c7f088b..935b28b2dd 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/OnboardingWalletFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/OnboardingWalletFragment.kt @@ -1,25 +1,40 @@ package com.tangem.tap.features.onboarding.products.wallet.ui +import android.graphics.BitmapFactory import android.os.Bundle import android.util.TypedValue import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.ImageView +import android.widget.ProgressBar import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.children import androidx.fragment.app.Fragment import androidx.transition.TransitionInflater import androidx.transition.TransitionManager import by.kirich1409.viewbindingdelegate.viewBinding import coil.load +import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.button.MaterialButton import com.google.android.material.tabs.TabLayoutMediator import com.tangem.common.CardIdFormatter import com.tangem.common.core.CardIdDisplayFormat +import com.tangem.common.extensions.guard import com.tangem.tangem_sdk_new.ui.widget.leapfrogWidget.LeapfrogWidget import com.tangem.tangem_sdk_new.ui.widget.leapfrogWidget.PropertyCalculator +import com.tangem.tap.common.compose.PinCodeWidget +import com.tangem.tap.common.extensions.beginDelayedTransition +import com.tangem.tap.common.extensions.configureSettings +import com.tangem.tap.common.extensions.dispatchDebugErrorNotification +import com.tangem.tap.common.extensions.dispatchDialogShow import com.tangem.tap.common.extensions.hide import com.tangem.tap.common.extensions.show +import com.tangem.tap.common.extensions.stop +import com.tangem.tap.common.feedback.SupportInfo +import com.tangem.tap.common.redux.global.GlobalAction +import com.tangem.tap.common.toggleWidget.IndeterminateProgressButtonWidget import com.tangem.tap.features.FragmentOnBackPressedHandler import com.tangem.tap.features.addBackPressHandler import com.tangem.tap.features.onboarding.products.wallet.redux.BackupAction @@ -28,24 +43,31 @@ import com.tangem.tap.features.onboarding.products.wallet.redux.BackupStep import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletAction import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletState import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletStep +import com.tangem.tap.features.onboarding.products.wallet.saltPay.UtorgWebChromeClient +import com.tangem.tap.features.onboarding.products.wallet.saltPay.UtorgWebViewClient +import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.SaltPayDialog +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayAction +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayState +import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.SaltPayRegistrationStep import com.tangem.tap.features.onboarding.products.wallet.ui.dialogs.AccessCodeDialog import com.tangem.tap.store import com.tangem.wallet.R import com.tangem.wallet.databinding.FragmentOnboardingWalletBinding +import com.tangem.wallet.databinding.LayoutOnboardingSaltpayBinding import com.tangem.wallet.databinding.ViewOnboardingProgressBinding import org.rekotlin.StoreSubscriber class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet), StoreSubscriber, FragmentOnBackPressedHandler { - private var accessCodeDialog: AccessCodeDialog? = null - private lateinit var cardsWidget: WalletCardsWidget - private val binding: FragmentOnboardingWalletBinding by viewBinding( - FragmentOnboardingWalletBinding::bind - ) - private val pbBinding: ViewOnboardingProgressBinding by viewBinding( - ViewOnboardingProgressBinding::bind) + private val pbBinding: ViewOnboardingProgressBinding by viewBinding(ViewOnboardingProgressBinding::bind) + private val binding: FragmentOnboardingWalletBinding by viewBinding(FragmentOnboardingWalletBinding::bind) + private val bindingSaltPay: LayoutOnboardingSaltpayBinding by lazy { binding.onboardingSaltpayContainer } + private val saltPayView: SaltPayView = SaltPayView(this) + + private lateinit var cardsWidget: WalletCardsWidget + private var accessCodeDialog: AccessCodeDialog? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -74,19 +96,19 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet), binding.viewPagerBackupInfo.adapter = BackupInfoAdapter() TabLayoutMediator( binding.tabLayoutBackupInfo, - binding.viewPagerBackupInfo + binding.viewPagerBackupInfo, ) { tab, position -> //Some implementation }.attach() (activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar) - store.dispatch(OnboardingWalletAction.Init) binding.toolbar.setNavigationOnClickListener { activity?.onBackPressed() } - - store.dispatch(OnboardingWalletAction.LoadArtwork) - addBackPressHandler(this) + + store.dispatch(OnboardingWalletAction.Init) + store.dispatch(OnboardingWalletAction.StartSaltPayCardActivation) + store.dispatch(OnboardingWalletAction.LoadArtwork) } override fun onStart() { @@ -108,23 +130,16 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet), requireActivity().invalidateOptionsMenu() - with(binding) { - loadImageIntoImageView(state.cardArtworkUrl, imvFrontCard) - loadImageIntoImageView(state.cardArtworkUrl, imvFirstBackupCard) - loadImageIntoImageView(state.cardArtworkUrl, imvSecondBackupCard) - } - - pbBinding.pbState.max = 6 + pbBinding.pbState.max = state.getMaxProgress() pbBinding.pbState.progress = state.getProgressStep() - when (state.step) { - OnboardingWalletStep.None -> { - } - OnboardingWalletStep.CreateWallet -> setupCreateWalletState() - OnboardingWalletStep.Backup -> setBackupState(state.backupState) - OnboardingWalletStep.Done -> { - - } + if (state.isSaltPay) { + saltPayView.newState(state) + } else { + loadImageIntoImageView(state.cardArtworkUrl, binding.imvFrontCard) + loadImageIntoImageView(state.cardArtworkUrl, binding.imvFirstBackupCard) + loadImageIntoImageView(state.cardArtworkUrl, binding.imvSecondBackupCard) + handleOnboardingStep(state) } } @@ -136,6 +151,14 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet), } } + private fun handleOnboardingStep(state: OnboardingWalletState) { + when (state.step) { + OnboardingWalletStep.CreateWallet -> setupCreateWalletState() + OnboardingWalletStep.Backup -> setBackupState(state.backupState, state.isSaltPay) + else -> {} + } + } + private fun setupCreateWalletState() = with(binding) { layoutButtonsCommon.btnMainAction.setText(R.string.onboarding_create_wallet_button_create_wallet) layoutButtonsCommon.btnMainAction.setOnClickListener { store.dispatch(OnboardingWalletAction.CreateWallet) } @@ -149,12 +172,11 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet), cardsWidget.toFolded(false) { startPostponedEnterTransition() } } - - private fun setBackupState(state: BackupState) { + private fun setBackupState(state: BackupState, isSaltPay: Boolean) { when (state.backupStep) { - BackupStep.InitBackup -> showBackupIntro(state) + BackupStep.InitBackup -> showBackupIntro(state, isSaltPay) BackupStep.ScanOriginCard -> showScanOriginCard() - BackupStep.AddBackupCards -> showAddBackupCards(state) + BackupStep.AddBackupCards -> showAddBackupCards(state, isSaltPay) BackupStep.SetAccessCode -> showSetAccessCode() BackupStep.EnterAccessCode -> showEnterAccessCode(state) BackupStep.ReenterAccessCode -> showReenterAccessCode(state) @@ -164,7 +186,7 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet), } } - private fun showBackupIntro(state: BackupState) = with(binding) { + private fun showBackupIntro(state: BackupState, isSaltPay: Boolean) = with(binding) { imvFirstBackupCard.show() imvSecondBackupCard.show() @@ -177,12 +199,11 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet), with(layoutButtonsCommon) { btnMainAction.text = getText(R.string.onboarding_button_backup_now) - btnAlternativeAction.text = getText(R.string.onboarding_button_skip_backup) - btnAlternativeAction.show(state.canSkipBackup) btnMainAction.setOnClickListener { store.dispatch(BackupAction.StartBackup) } - btnAlternativeAction.setOnClickListener { store.dispatch(BackupAction.DismissBackup) } - btnAlternativeAction.show(!state.isSaltPay) + btnAlternativeAction.text = getText(R.string.onboarding_button_skip_backup) + btnAlternativeAction.setOnClickListener { store.dispatch(BackupAction.DismissBackup) } + btnAlternativeAction.show(state.canSkipBackup && !isSaltPay) } startPostponedEnterTransition() @@ -217,7 +238,7 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet), imvSecondBackupCard.show() } - private fun showAddBackupCards(state: BackupState) = with(binding) { + private fun showAddBackupCards(state: BackupState, isSaltPay: Boolean) = with(binding) { prepareBackupView() accessCodeDialog?.dismiss() @@ -232,6 +253,15 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet), layoutButtonsAddCards.btnAddCard.isEnabled = false } + layoutButtonsAddCards.btnContinue.text = getText(R.string.onboarding_button_finalize_backup) + layoutButtonsAddCards.btnContinue.setOnClickListener { store.dispatch(BackupAction.FinishAddingBackupCards) } + layoutButtonsAddCards.btnContinue.isEnabled = state.backupCardsNumber != 0 + + if (isSaltPay) { + saltPayView.showAddBackupCards(state) + return@with + } + when (state.backupCardsNumber) { 0 -> { cardsWidget.toFan { @@ -258,10 +288,6 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet), cardsWidget.getSecondBackupCardView().animate().alpha(1f).duration = 400 } } - - layoutButtonsAddCards.btnContinue.text = getText(R.string.onboarding_button_finalize_backup) - layoutButtonsAddCards.btnContinue.setOnClickListener { store.dispatch(BackupAction.FinishAddingBackupCards) } - layoutButtonsAddCards.btnContinue.isEnabled = state.backupCardsNumber != 0 } private fun showSetAccessCode() { @@ -307,7 +333,7 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet), tvHeader.text = getText(R.string.onboarding_title_prepare_origin) tvBody.text = getString( R.string.onboarding_subtitle_scan_primary_card_format, - state.primaryCardId?.let { cardIdFormatter.getFormattedCardId(it) } + state.primaryCardId?.let { cardIdFormatter.getFormattedCardId(it) }, ) layoutButtonsCommon.btnMainAction.text = getText(R.string.onboarding_button_backup_origin) layoutButtonsCommon.btnMainAction.setOnClickListener { store.dispatch(BackupAction.WritePrimaryCard) } @@ -350,15 +376,14 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet), } val cardIdFormatter = CardIdFormatter(CardIdDisplayFormat.LastMasked(4)) - tvHeader.text = - getString(R.string.onboarding_title_backup_card_format, cardNumber) + tvHeader.text = getString(R.string.onboarding_title_backup_card_format, cardNumber) tvBody.text = getString( R.string.onboarding_subtitle_scan_backup_card_format, - cardIdFormatter.getFormattedCardId(state.backupCardIds[cardNumber - 1]) + cardIdFormatter.getFormattedCardId(state.backupCardIds[cardNumber - 1]), ) layoutButtonsCommon.btnMainAction.text = getString( R.string.onboarding_button_backup_card_format, - cardNumber + cardNumber, ) layoutButtonsCommon.btnMainAction.setOnClickListener { store.dispatch(BackupAction.WriteBackupCard(cardNumber)) @@ -398,4 +423,192 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet), override fun handleOnBackPressed() { store.dispatch(OnboardingWalletAction.OnBackPressed) } + + private class SaltPayView( + private val walletFragment: OnboardingWalletFragment, + ) { + + private val toolbar: MaterialToolbar by lazy { walletFragment.binding.toolbar } + + private var progressButton: SaltPayProgressButton? = null + + fun newState(state: OnboardingWalletState) { + handleCardArtworks(state) + when (state.step) { + OnboardingWalletStep.SaltPay -> setSaltPayStep(state.onboardingSaltPayState) + OnboardingWalletStep.Backup -> { + if (state.backupState.backupStep == BackupStep.Finished) { + // if backup finished -> switch OnboardingWalletStep to the SaltPay + store.dispatch(OnboardingWalletAction.GetToSaltPayStep) + } else { + // if not -> back to the standard backup process + walletFragment.handleOnboardingStep(state) + } + } + else -> walletFragment.handleOnboardingStep(state) + } + } + + private fun handleCardArtworks(state: OnboardingWalletState) = with(walletFragment.binding) { + if (state.onboardingSaltPayState?.saltPayCardArtworkUrl == null) { + // if saltPay url not loaded -> load from resource + val bitmap = BitmapFactory.decodeResource(walletFragment.resources, R.drawable.img_salt_pay_visa) + imvFrontCard.setImageBitmap(bitmap) + } else { + walletFragment.loadImageIntoImageView(state.onboardingSaltPayState.saltPayCardArtworkUrl, imvFrontCard) + } + walletFragment.loadImageIntoImageView(state.cardArtworkUrl, imvFirstBackupCard) + + //TODO: at now we can hide the image only by changing alpha channel to 0, because + // the OnboardingWalletFragment and WalletCardsWidget manipulate it visibility through changing + // View.VISIBILITY states. + imvSecondBackupCard.alpha = 0f + } + + fun showAddBackupCards(state: BackupState) = with(walletFragment.binding) { + walletFragment.cardsWidget.getSecondBackupCardView().alpha = 0f + when (state.backupCardsNumber) { + 0 -> { + walletFragment.cardsWidget.toFan { + walletFragment.cardsWidget.getFirstBackupCardView().animate().alpha(0.6f).duration = 200 + } + tvHeader.text = walletFragment.getText(R.string.onboarding_title_no_backup_cards) + tvBody.text = walletFragment.getText(R.string.onboarding_subtitle_no_backup_cards) + } + 1 -> { + tvHeader.text = walletFragment.getText(R.string.onboarding_title_one_backup_card) + tvBody.text = walletFragment.getText(R.string.onboarding_subtitle_one_backup_card) + walletFragment.cardsWidget.toFan(false) + walletFragment.cardsWidget.getFirstBackupCardView().animate().alpha(1f).duration = 400 + } + } + } + + private fun setSaltPayStep(onboardingSaltPayState: OnboardingSaltPayState?) { + val state = onboardingSaltPayState.guard { + store.dispatchDebugErrorNotification("SaltPayView: setSaltPayStep: onboardingSaltPayState = null") + return + } + + walletFragment.binding.onboardingWalletContainer.hide() + walletFragment.bindingSaltPay.onboardingSaltpayContainer.show() + + when (state.step) { + SaltPayRegistrationStep.NoGas -> handleNoGas() + SaltPayRegistrationStep.NeedPin -> handleNeedPin() + SaltPayRegistrationStep.CardRegistration -> handleCardRegistration() + SaltPayRegistrationStep.Kyc -> handleKyc() + SaltPayRegistrationStep.KycStart -> handleKycStart(state) + SaltPayRegistrationStep.KycWaiting -> handleKycWaiting() + SaltPayRegistrationStep.Finished -> handleFinished() + } + progressButton?.changeState(state.mainButtonState) + } + + private fun handleNoGas() { + store.dispatchDialogShow(SaltPayDialog.NoFundsForActivation) + } + + private fun handleNeedPin() = with(walletFragment.bindingSaltPay) { + toolbar.title = walletFragment.getString(R.string.onboarding_navbar_pin) + showOnlyView(pinCode.root) { + progressButton = SaltPayProgressButton(pinCode.root) + progressButton?.mainView?.isEnabled = false + + var enteredPinCode = "" + pinCode.flPinCodeContainer.setContent { + PinCodeWidget { code, isLastSymbolEntered -> + enteredPinCode = code + progressButton?.mainView?.isEnabled = isLastSymbolEntered + } + } + pinCode.btnSetPin.setOnClickListener { + store.dispatch(OnboardingSaltPayAction.TrySetPin(enteredPinCode)) + } + } + } + + private fun handleCardRegistration() = with(walletFragment.bindingSaltPay) { + toolbar.title = walletFragment.getString(R.string.onboarding_navbar_register_wallet) + showOnlyView(connectCard.root) { + progressButton = SaltPayProgressButton(connectCard.root) + connectCard.btnConnect.setOnClickListener { + store.dispatch(OnboardingSaltPayAction.RegisterCard) + } + } + } + + private fun handleKyc() = with(walletFragment.bindingSaltPay) { + toolbar.title = walletFragment.getString(R.string.onboarding_navbar_kyc_start) + showOnlyView(verifyIdentity.root) { + progressButton = SaltPayProgressButton(verifyIdentity.root) + verifyIdentity.btnVerify.setOnClickListener { + store.dispatch(OnboardingSaltPayAction.KYCStart) + } + } + } + + private fun handleKycStart(state: OnboardingSaltPayState) = with(walletFragment.bindingSaltPay) { + toolbar.title = walletFragment.getString(R.string.onboarding_navbar_kyc_start) + showOnlyView(webvVerifyIdentity) { + progressButton = null + webvVerifyIdentity.configureSettings() + webvVerifyIdentity.webViewClient = UtorgWebViewClient( + successUrl = state.saltPayManager.kycUrlProvider.doneUrl, + onSuccess = { + webvVerifyIdentity.stop() + store.dispatch(OnboardingSaltPayAction.OnFinishKYC) + }, + ) + webvVerifyIdentity.webChromeClient = UtorgWebChromeClient(walletFragment.requireActivity()) + webvVerifyIdentity.loadUrl(state.saltPayManager.kycUrlProvider.requestUrl) + } + } + + private fun handleKycWaiting() = with(walletFragment.bindingSaltPay) { + toolbar.title = walletFragment.getString(R.string.onboarding_navbar_kyc_waiting) + showOnlyView(verifyIdentityInProgress.root) { + progressButton = SaltPayProgressButton(verifyIdentityInProgress.root) + verifyIdentityInProgress.btnOpenSupportChat.setOnClickListener { + store.dispatch(GlobalAction.OpenChat(SupportInfo())) + } + verifyIdentityInProgress.btnRefresh.setOnClickListener { + store.dispatch(OnboardingSaltPayAction.Update) + } + } + } + + private fun handleFinished() { + walletFragment.binding.onboardingRoot.beginDelayedTransition() + walletFragment.bindingSaltPay.onboardingSaltpayContainer.hide() + walletFragment.binding.onboardingWalletContainer.show() + walletFragment.showSuccess() + } + + private fun showOnlyView(view: View, onShowListener: (() -> Unit)? = null) { + // onboardingSaltpayContainer.beginDelayedTransition() + walletFragment.bindingSaltPay.onboardingSaltpayContainer.children + .filter { it.id != view.id } + .forEach { it.hide() } + view.show { onShowListener?.invoke() } + } + + private class SaltPayProgressButton( + root: ViewGroup, + ) : IndeterminateProgressButtonWidget(findButton(root), findProgress(root)) { + companion object { + fun findButton(view: ViewGroup): MaterialButton { + return findContainer(view).children.firstOrNull { it is MaterialButton } as MaterialButton + } + + fun findProgress(view: ViewGroup): View { + return findContainer(view).children.firstOrNull { it is ProgressBar }!! + } + + fun findContainer(view: ViewGroup): ViewGroup { + return view.children.first { it.id == R.id.btn_container } as ViewGroup + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/ConfirmDiscardingBackupDialog.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/ConfirmDiscardingBackupDialog.kt index 8b88890be8..4d35050937 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/ConfirmDiscardingBackupDialog.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/ConfirmDiscardingBackupDialog.kt @@ -14,7 +14,7 @@ class ConfirmDiscardingBackupDialog { setTitle(R.string.welcome_interrupted_backup_discard_title) setMessage(R.string.welcome_interrupted_backup_discard_message) setPositiveButton(R.string.welcome_interrupted_backup_discard_resume) { _, _ -> - store.dispatch(BackupAction.ResumeBackup) + store.dispatch(BackupAction.ResumeFoundUnfinishedBackup) } setNegativeButton(R.string.welcome_interrupted_backup_discard_discard) { _, _ -> store.dispatch(BackupAction.DiscardSavedBackup) diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/UnfinishedBackupFoundDialog.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/UnfinishedBackupFoundDialog.kt index b51a53e29f..697dc71f67 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/UnfinishedBackupFoundDialog.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/UnfinishedBackupFoundDialog.kt @@ -16,7 +16,7 @@ class UnfinishedBackupFoundDialog { setMessage(R.string.welcome_interrupted_backup_alert_message) setPositiveButton(R.string.welcome_interrupted_backup_alert_resume) { _, _ -> store.dispatch(GlobalAction.HideDialog) - store.dispatch(BackupAction.ResumeBackup) + store.dispatch(BackupAction.ResumeFoundUnfinishedBackup) } setNegativeButton(R.string.welcome_interrupted_backup_alert_discard) { _, _ -> store.dispatch(GlobalAction.HideDialog) diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/service/OnboardingWalletService.kt b/app/src/main/java/com/tangem/tap/features/onboarding/service/OnboardingWalletService.kt deleted file mode 100644 index db7f77b99c..0000000000 --- a/app/src/main/java/com/tangem/tap/features/onboarding/service/OnboardingWalletService.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.features.onboarding.service - -//class OnboardingWalletService( -// override val fromScreen: AppScreen, -// override var scanResponse: ScanResponse, -// cardInfoStorage: UsedCardsPrefStorage, -// walletManagerFactory: WalletManagerFactory, -//) : ProductOnboardingService(cardInfoStorage, walletManagerFactory) { -// -// override fun navigateToScreen(): AppScreen { -// return AppScreen.OnboardingWallet -// } -//} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/AddCustomTokenScreen.kt b/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/AddCustomTokenScreen.kt index 14f9fa3397..93066fc926 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/AddCustomTokenScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/addCustomToken/compose/AddCustomTokenScreen.kt @@ -1,11 +1,37 @@ package com.tangem.tap.features.tokens.addCustomToken.compose -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.* +import androidx.compose.material.BottomSheetScaffold +import androidx.compose.material.BottomSheetScaffoldState +import androidx.compose.material.BottomSheetState +import androidx.compose.material.BottomSheetValue +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ExtendedFloatingActionButton +import androidx.compose.material.FabPosition +import androidx.compose.material.FloatingActionButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.runtime.* +import androidx.compose.material.rememberBottomSheetScaffoldState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -15,14 +41,23 @@ import androidx.compose.ui.unit.dp import com.tangem.domain.AddCustomTokenError import com.tangem.domain.common.form.DataField import com.tangem.domain.common.form.FieldId -import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.* +import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.ContractAddress +import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Decimals +import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.DerivationPath +import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Name +import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Network +import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.Symbol import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenState import com.tangem.domain.features.addCustomToken.redux.ScreenState import com.tangem.domain.features.addCustomToken.redux.ViewStates import com.tangem.domain.redux.domainStore -import com.tangem.tap.common.compose.* -import com.tangem.tap.common.moduleMessage.ModuleMessageConverter +import com.tangem.tap.common.compose.AddCustomTokenWarning +import com.tangem.tap.common.compose.ClosePopupTrigger +import com.tangem.tap.common.compose.ComposeDialogManager +import com.tangem.tap.common.compose.ToggledRippleTheme +import com.tangem.tap.common.compose.keyboardAsState +import com.tangem.tap.domain.moduleMessage.ModuleMessageConverter import com.tangem.tap.features.tokens.addCustomToken.compose.test.TestCase import com.tangem.tap.features.tokens.addCustomToken.compose.test.TestCasesList import com.tangem.wallet.R diff --git a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/MultiWalletMiddleware.kt b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/MultiWalletMiddleware.kt index d7e6ceba13..ba68023190 100644 --- a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/MultiWalletMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/MultiWalletMiddleware.kt @@ -143,8 +143,8 @@ class MultiWalletMiddleware { is WalletAction.MultiWallet.ShowWalletBackupWarning -> Unit is WalletAction.MultiWallet.BackupWallet -> { store.state.globalState.scanResponse?.let { + store.dispatch(GlobalAction.Onboarding.Start(it, canSkipBackup = false)) store.dispatch(NavigationAction.NavigateTo(AppScreen.OnboardingWallet)) - store.dispatch(GlobalAction.Onboarding.Start(it, fromHomeScreen = false)) } } is WalletAction.MultiWallet.ScanToGetDerivations -> { diff --git a/app/src/main/java/com/tangem/tap/features/wallet/ui/WalletFragment.kt b/app/src/main/java/com/tangem/tap/features/wallet/ui/WalletFragment.kt index 27d49105a4..b7748269e4 100644 --- a/app/src/main/java/com/tangem/tap/features/wallet/ui/WalletFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/wallet/ui/WalletFragment.kt @@ -42,7 +42,6 @@ import com.tangem.wallet.R import com.tangem.wallet.databinding.FragmentWalletBinding import org.rekotlin.StoreSubscriber - class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber { private lateinit var warningsAdapter: WarningMessagesAdapter @@ -89,7 +88,7 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber(jsonData) ?: SaltPayRegistratorData() + } + isFetching = false + + return data + } + + companion object { + private const val REGISTRATION_DATA_KEY = "saltpay_registration_data" + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_connecting_socket.xml b/app/src/main/res/drawable/ic_connecting_socket.xml new file mode 100644 index 0000000000..a07fbe5515 --- /dev/null +++ b/app/src/main/res/drawable/ic_connecting_socket.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_in_progress.xml b/app/src/main/res/drawable/ic_in_progress.xml new file mode 100644 index 0000000000..17f814a06d --- /dev/null +++ b/app/src/main/res/drawable/ic_in_progress.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_passport.xml b/app/src/main/res/drawable/ic_passport.xml new file mode 100644 index 0000000000..e793f05866 --- /dev/null +++ b/app/src/main/res/drawable/ic_passport.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_tangem.xml b/app/src/main/res/drawable/ic_tangem.xml index 5140d0444c..93f8741777 100644 --- a/app/src/main/res/drawable/ic_tangem.xml +++ b/app/src/main/res/drawable/ic_tangem.xml @@ -1,7 +1,15 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/img_salt_pay_visa.png b/app/src/main/res/drawable/img_salt_pay_visa.png new file mode 100644 index 0000000000000000000000000000000000000000..19f69d5eb6987b92121aaa46334a97ae0d26751f GIT binary patch literal 60037 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 -L>W84`oU;LzELq}BG^h+*aKQy| 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 MHT0iWtJ{3|KUouMmjD0& literal 0 HcmV?d00001 diff --git a/app/src/main/res/layout/fragment_onboarding_wallet.xml b/app/src/main/res/layout/fragment_onboarding_wallet.xml index 903a3c218b..aa2c8c7494 100644 --- a/app/src/main/res/layout/fragment_onboarding_wallet.xml +++ b/app/src/main/res/layout/fragment_onboarding_wallet.xml @@ -28,16 +28,17 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="match_parent" + android:clipChildren="false"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:layout_height="match_parent" + android:visibility="gone" /> - - - - - - + + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + app:layout_constraintStart_toStartOf="@id/btn_continue" + tools:text="+ Add backup card" /> + app:layout_constraintStart_toStartOf="parent" + tools:text="Main Action" /> \ No newline at end of file diff --git a/app/src/main/res/layout/layout_onboarding_buttons_common.xml b/app/src/main/res/layout/layout_onboarding_buttons_common.xml index e99f7dfe4a..9bd99de16a 100644 --- a/app/src/main/res/layout/layout_onboarding_buttons_common.xml +++ b/app/src/main/res/layout/layout_onboarding_buttons_common.xml @@ -18,13 +18,10 @@ diff --git a/app/src/main/res/layout/layout_onboarding_saltpay.xml b/app/src/main/res/layout/layout_onboarding_saltpay.xml new file mode 100644 index 0000000000..193cf0753c --- /dev/null +++ b/app/src/main/res/layout/layout_onboarding_saltpay.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/layout_onboarding_saltpay_connect_card.xml b/app/src/main/res/layout/layout_onboarding_saltpay_connect_card.xml new file mode 100644 index 0000000000..0c86022df3 --- /dev/null +++ b/app/src/main/res/layout/layout_onboarding_saltpay_connect_card.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/layout_onboarding_saltpay_pin_code.xml b/app/src/main/res/layout/layout_onboarding_saltpay_pin_code.xml new file mode 100644 index 0000000000..08e2bfe29b --- /dev/null +++ b/app/src/main/res/layout/layout_onboarding_saltpay_pin_code.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/layout_onboarding_saltpay_verify_identity.xml b/app/src/main/res/layout/layout_onboarding_saltpay_verify_identity.xml new file mode 100644 index 0000000000..f1d03e8347 --- /dev/null +++ b/app/src/main/res/layout/layout_onboarding_saltpay_verify_identity.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/layout_onboarding_saltpay_verify_identity_in_progress.xml b/app/src/main/res/layout/layout_onboarding_saltpay_verify_identity_in_progress.xml new file mode 100644 index 0000000000..efd4aed299 --- /dev/null +++ b/app/src/main/res/layout/layout_onboarding_saltpay_verify_identity_in_progress.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/lp_onboarding_create_wallet.xml b/app/src/main/res/layout/lp_onboarding_create_wallet.xml index 3c5cd91655..68e87f3e61 100644 --- a/app/src/main/res/layout/lp_onboarding_create_wallet.xml +++ b/app/src/main/res/layout/lp_onboarding_create_wallet.xml @@ -1,7 +1,7 @@ diff --git a/app/src/main/res/layout/lp_onboarding_done.xml b/app/src/main/res/layout/lp_onboarding_done.xml index e6f1cb76fa..8f7be0c671 100644 --- a/app/src/main/res/layout/lp_onboarding_done.xml +++ b/app/src/main/res/layout/lp_onboarding_done.xml @@ -1,7 +1,7 @@ diff --git a/app/src/main/res/layout/lp_onboarding_done_activation.xml b/app/src/main/res/layout/lp_onboarding_done_activation.xml index 70be6417e0..02512421c5 100644 --- a/app/src/main/res/layout/lp_onboarding_done_activation.xml +++ b/app/src/main/res/layout/lp_onboarding_done_activation.xml @@ -1,7 +1,7 @@ diff --git a/app/src/main/res/layout/lp_onboarding_done_activation_twins.xml b/app/src/main/res/layout/lp_onboarding_done_activation_twins.xml index eeea439aad..c465795c75 100644 --- a/app/src/main/res/layout/lp_onboarding_done_activation_twins.xml +++ b/app/src/main/res/layout/lp_onboarding_done_activation_twins.xml @@ -1,7 +1,7 @@ diff --git a/app/src/main/res/layout/lp_onboarding_topup_wallet.xml b/app/src/main/res/layout/lp_onboarding_topup_wallet.xml index dc6efbcc96..e4e5587025 100644 --- a/app/src/main/res/layout/lp_onboarding_topup_wallet.xml +++ b/app/src/main/res/layout/lp_onboarding_topup_wallet.xml @@ -1,7 +1,7 @@ diff --git a/app/src/main/res/layout/lp_onboarding_topup_wallet_twins.xml b/app/src/main/res/layout/lp_onboarding_topup_wallet_twins.xml index a7ab2ca9fd..66351f4062 100644 --- a/app/src/main/res/layout/lp_onboarding_topup_wallet_twins.xml +++ b/app/src/main/res/layout/lp_onboarding_topup_wallet_twins.xml @@ -1,7 +1,7 @@ diff --git a/dependencies.gradle b/dependencies.gradle index b23c84e56f..4ac18f3aff 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -1,9 +1,10 @@ ext.versions = [ kotlin : '1.6.10', build_gradle : '7.1.3', - tangem_card_sdk : 'develop-163', - tangem_blockchain_sdk: 'develop-121', - // tangem_blockchain_sdk: '0.0.1', + // tangem_card_sdk : 'develop-164', + tangem_card_sdk : '0.0.1', + // tangem_blockchain_sdk: 'develop-122', + tangem_blockchain_sdk: '0.0.1', ] ext.environmentConfig = [