Updated on 2026-08-14

This commit is contained in:
Tangem 2022-10-18 16:07:17 +03:00
commit aaf95cdae2
150 changed files with 6614 additions and 1117 deletions

View file

@ -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'

@ -1 +1 @@
Subproject commit 8a36217ba816e1cc7cbc7290ffb948282ec15f6b
Subproject commit cf6ea50867477f97655da9da47ff94987e8f3354

1832
app/src/main/assets/tos.html Normal file

File diff suppressed because it is too large Load diff

View file

@ -13,9 +13,12 @@ import com.tangem.TangemSdk
import com.tangem.domain.common.FeatureCoroutineExceptionHandler
import com.tangem.operations.backup.BackupService
import com.tangem.tangem_sdk_new.extensions.init
import com.tangem.tap.common.ActivityResultCallbackHolder
import com.tangem.tap.common.DialogManager
import com.tangem.tap.common.IntentHandler
import com.tangem.tap.common.OnActivityResultCallback
import com.tangem.tap.common.SnackbarHandler
import com.tangem.tap.common.extensions.copyToClipboard
import com.tangem.tap.common.redux.NotificationsHandler
import com.tangem.tap.common.redux.global.AndroidResources
import com.tangem.tap.common.redux.global.GlobalAction
@ -47,15 +50,18 @@ private val mainCoroutineContext: CoroutineContext
get() = Job() + Dispatchers.Main + FeatureCoroutineExceptionHandler.create("mainScope")
val mainScope = CoroutineScope(mainCoroutineContext)
class MainActivity : AppCompatActivity(), SnackbarHandler {
class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbackHolder {
private var snackbar: Snackbar? = null
private val dialogManager = DialogManager()
private val intentHandler = IntentHandler()
private val binding: ActivityMainBinding by viewBinding(ActivityMainBinding::bind)
private val onActivityResultCallbacks = mutableListOf<OnActivityResultCallback>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
internalActivity = this
setContentView(R.layout.activity_main)
systemActions()
store.dispatch(NavigationAction.ActivityCreated(WeakReference(this)))
@ -67,18 +73,17 @@ class MainActivity : AppCompatActivity(), SnackbarHandler {
store.dispatch(GlobalAction.SetResources(getAndroidResources()))
store.dispatch(
ShopAction.CheckIfGooglePayAvailable(
GooglePayService(createPaymentsClient(this), this)
)
GooglePayService(createPaymentsClient(this), this),
),
)
}
private fun getAndroidResources(): AndroidResources {
return AndroidResources(
AndroidResources.RString(
R.string.copy_toast_msg,
R.string.details_notification_erase_wallet_not_possible
)
R.string.details_notification_erase_wallet_not_possible,
),
)
}
@ -92,7 +97,7 @@ class MainActivity : AppCompatActivity(), SnackbarHandler {
supportFragmentManager.registerFragmentLifecycleCallbacks(
NavBarInsetsFragmentLifecycleCallback(),
true
true,
)
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
@ -137,7 +142,7 @@ class MainActivity : AppCompatActivity(), SnackbarHandler {
if (snackbar != null) return
snackbar = Snackbar.make(
binding.fragmentContainer, getString(text), Snackbar.LENGTH_INDEFINITE
binding.fragmentContainer, getString(text), Snackbar.LENGTH_INDEFINITE,
)
if (buttonTitle != null && action != null) {
snackbar?.setAction(getString(buttonTitle), action)
@ -152,12 +157,29 @@ class MainActivity : AppCompatActivity(), SnackbarHandler {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
onActivityResultCallbacks.forEach { it(requestCode, resultCode, data) }
when (requestCode) {
LOAD_PAYMENT_DATA_REQUEST_CODE -> {
store.dispatch(
ShopAction.BuyWithGooglePay.HandleGooglePayResponse(resultCode, data)
ShopAction.BuyWithGooglePay.HandleGooglePayResponse(resultCode, data),
)
}
}
}
override fun addOnActivityResultCallback(callback: OnActivityResultCallback) {
onActivityResultCallbacks.remove(callback)
onActivityResultCallbacks.add(callback)
}
override fun removeOnActivityResultCallback(callback: OnActivityResultCallback) {
onActivityResultCallbacks.remove(callback)
}
}
lateinit var internalActivity: AppCompatActivity
//TODO: delete
fun copyToClipboard(text: String) {
internalActivity.copyToClipboard(text)
}

View file

@ -32,8 +32,10 @@ import com.tangem.tap.domain.walletconnect.WalletConnectRepository
import com.tangem.tap.network.NetworkConnectivity
import com.tangem.tap.persistence.PreferencesStorage
import com.tangem.wallet.BuildConfig
import com.zendesk.logger.Logger
import org.rekotlin.Store
import timber.log.Timber
import zendesk.chat.Chat
val store = Store(
reducer = ::appReducer,
@ -81,7 +83,7 @@ class TapApplication : Application(), ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return createCoilImageLoader(
context = this,
logEnabled = LogConfig.imageLoader
logEnabled = LogConfig.imageLoader,
)
}
@ -140,12 +142,11 @@ class TapApplication : Application(), ImageLoaderFactory {
infoHolder = additionalFeedbackInfo,
logCollector = tangemLogCollector,
preferencesStorage = preferencesStorage,
logEnabled = LogConfig.zendesk,
)
feedbackManager.initChat(
context = context,
zendeskConfig = config.zendesk!!,
)
feedbackManager.chatInitializer = { zendeskConfig ->
Chat.INSTANCE.init(context, zendeskConfig.accountKey, zendeskConfig.appId)
Logger.setLoggable(LogConfig.zendesk)
}
store.dispatch(GlobalAction.SetFeedbackManager(feedbackManager))
}

View file

@ -0,0 +1,13 @@
package com.tangem.tap.common
import android.content.Intent
/**
[REDACTED_AUTHOR]
*/
interface ActivityResultCallbackHolder {
fun addOnActivityResultCallback(callback: OnActivityResultCallback)
fun removeOnActivityResultCallback(callback: OnActivityResultCallback)
}
typealias OnActivityResultCallback = (Int, Int, Intent?) -> Unit

View file

@ -17,6 +17,9 @@ import com.tangem.tap.features.onboarding.AddressInfoBottomSheetDialog
import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsAction
import com.tangem.tap.features.onboarding.products.twins.ui.dialog.CreateWalletInterruptDialog
import com.tangem.tap.features.onboarding.products.wallet.redux.BackupDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.NoFundsForActivationDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.RegistrationErrorDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.SaltPayDialog
import com.tangem.tap.features.onboarding.products.wallet.ui.dialogs.AddMoreBackupCardsDialog
import com.tangem.tap.features.onboarding.products.wallet.ui.dialogs.BackupInProgressDialog
import com.tangem.tap.features.onboarding.products.wallet.ui.dialogs.ConfirmDiscardingBackupDialog
@ -126,6 +129,8 @@ class DialogManager : StoreSubscriber<GlobalState> {
is BackupDialog.BackupInProgress -> BackupInProgressDialog.create(context)
is BackupDialog.UnfinishedBackupFound -> UnfinishedBackupFoundDialog.create(context)
is BackupDialog.ConfirmDiscardingBackup -> ConfirmDiscardingBackupDialog.create(context)
is SaltPayDialog.NoFundsForActivation -> NoFundsForActivationDialog.create(context)
is SaltPayDialog.RegistrationError -> RegistrationErrorDialog.create(context, state.dialog)
is WalletDialog.CurrencySelectionDialog ->
CurrencySelectionDialog.create(state.dialog, context)
is WalletDialog.ChooseTradeActionDialog ->

View file

@ -15,6 +15,10 @@ fun postBackground(ms: Long = 0, func: Runnable) {
if (ms == 0L) backgroundHandler.post { func.run() } else backgroundHandler.postDelayed(func, ms)
}
fun postUiDelayBg(ms: Long, func: Runnable) {
backgroundHandler.postDelayed({ uiHandler.post(func) }, ms)
}
fun post(ms: Long = 0, func: Runnable) {
if (ms == 0L) {
func.run()

View file

@ -65,7 +65,7 @@ object TangemSdk {
is TangemSdkError.Busy -> TangemSdkError.Busy()
is TangemSdkError.MissingPreflightRead -> TangemSdkError.MissingPreflightRead()
is TangemSdkError.WrongCardNumber -> TangemSdkError.WrongCardNumber()
is TangemSdkError.WrongCardType -> TangemSdkError.WrongCardType()
is TangemSdkError.WrongCardType -> TangemSdkError.WrongCardType(null)
is TangemSdkError.CardError -> TangemSdkError.CardError()
is TangemSdkError.NotSupportedFirmwareVersion -> TangemSdkError.NotSupportedFirmwareVersion()
is TangemSdkError.WalletError -> TangemSdkError.WalletError()

View file

@ -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<DomainDialog?>) {
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)

View file

@ -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),

View file

@ -0,0 +1,172 @@
package com.tangem.tap.common.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.text.isDigitsOnly
/**
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun PinCodeWidget(
config: PinViewConfig = tangemPinConfig,
onPinChanged: (String, Boolean) -> Unit = { pin, isLastSymbolEntered -> },
) {
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val rTextFieldValue = remember { mutableStateOf(TextFieldValue("")) }
val indexedSymbols: List<String?> = createPinSymbolsList(config.pinsCount, rTextFieldValue.value.text)
fun isLastSymbolEntered(): Boolean = rTextFieldValue.value.text.length == config.pinsCount
fun handleOnTextFieldValueChanged(value: TextFieldValue) {
if (!value.text.isDigitsOnly()) return
if (value.text.length <= config.pinsCount) {
rTextFieldValue.value = value
onPinChanged(value.text, isLastSymbolEntered())
}
}
Box(
modifier = config.modifier
.pointerInput(Unit) {
detectTapGestures {
focusRequester.requestFocus()
keyboardController?.show()
}
},
) {
Row {
for (index in 0 until config.pinsCount) {
PinElement(
config = config,
pinSymbol = indexedSymbols[index] ?: "",
)
}
}
TextField(
modifier = Modifier
.alpha(0f)
.size(1.dp)
.align(Alignment.Center)
.focusRequester(focusRequester),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
imeAction = if (isLastSymbolEntered()) ImeAction.Done else ImeAction.Next,
),
keyboardActions = KeyboardActions(
onDone = { keyboardController?.hide() },
),
value = rTextFieldValue.value,
onValueChange = ::handleOnTextFieldValueChanged,
)
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
@Composable
private fun PinElement(
config: PinViewConfig,
pinSymbol: String,
) {
Box(Modifier.padding(config.pinBoxPadding)) {
Box(config.pinBoxModifier) {
Text(
text = pinSymbol,
modifier = config.pinTextModifier.align(Alignment.Center),
style = config.pinsTextStyle ?: LocalTextStyle.current,
)
}
}
}
@Composable
private fun HiddenInputField() {
}
private fun createPinSymbolsList(size: Int, text: String): List<String?> = List(size) {
try {
text[it].toString()
} catch (ex: IndexOutOfBoundsException) {
null
}
}
data class PinViewConfig(
val modifier: Modifier = Modifier,
val pinBoxModifier: Modifier = Modifier,
val pinBoxPadding: Dp = 0.dp,
val pinTextModifier: Modifier = Modifier,
val pinsCount: Int = 4,
val pinsTextStyle: TextStyle? = null,
)
private val tangemPinConfig = PinViewConfig(
modifier = Modifier
.wrapContentSize(),
pinBoxModifier = Modifier
.width(42.dp)
.height(56.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFFF0F0F0)),
pinBoxPadding = 6.dp,
pinTextModifier = Modifier,
pinsCount = 4,
pinsTextStyle = TextStyle(
fontWeight = FontWeight(500),
fontSize = 24.sp,
),
)
@Preview
@Composable
fun PinCodeWidgetPreview() {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
PinCodeWidget(tangemPinConfig)
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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<T> 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
}

View file

@ -0,0 +1,19 @@
package com.tangem.tap.common.extensions
import android.webkit.WebView
/**
[REDACTED_AUTHOR]
*/
fun WebView.configureSettings() {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
}
}
fun WebView.stop() {
stopLoading()
pauseTimers()
destroy()
}

View file

@ -10,7 +10,6 @@ import com.tangem.tap.foregroundActivityObserver
import com.tangem.tap.persistence.PreferencesStorage
import com.tangem.tap.withForegroundActivity
import com.tangem.wallet.R
import com.zendesk.logger.Logger
import timber.log.Timber
import zendesk.chat.Chat
import zendesk.chat.ChatConfiguration
@ -30,20 +29,17 @@ class FeedbackManager(
val infoHolder: AdditionalFeedbackInfo,
private val logCollector: TangemLogCollector,
private val preferencesStorage: PreferencesStorage,
private val logEnabled: Boolean = false,
) {
fun initChat(
context: Context,
zendeskConfig: ZendeskConfig,
) {
Chat.INSTANCE.init(
context,
zendeskConfig.accountKey,
zendeskConfig.appId
)
private var lastUsedConfigForInitialization: ZendeskConfig? = null
// Zendesk logs
Logger.setLoggable(logEnabled)
var chatInitializer: ((ZendeskConfig) -> Unit)? = null
fun initChat(zendeskConfig: ZendeskConfig) {
// prevent double initialization with the same config
if (lastUsedConfigForInitialization == zendeskConfig) return
lastUsedConfigForInitialization = zendeskConfig
chatInitializer?.invoke(zendeskConfig)
}
fun sendEmail(feedbackData: FeedbackData, onFail: ((Exception) -> Unit)? = null) {
@ -55,7 +51,7 @@ class FeedbackManager(
subject = activity.getString(feedbackData.subjectResId),
message = feedbackData.joinTogether(activity, infoHolder),
file = fileLog,
onFail = onFail
onFail = onFail,
)
}
}

View file

@ -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<ModuleMessage, String> {
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}"
}
}

View file

@ -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<DomainModuleMessage, String?> {
override fun convert(message: DomainModuleMessage): String? {
return when (message) {
is DomainError -> DomainErrorConverter(context).convert(message)
else -> null
}
}
}

View file

@ -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,

View file

@ -12,6 +12,7 @@ val logMiddleware: Middleware<AppState> = { dispatch, appState ->
{ action ->
if (LogConfig.storeAction) {
Timber.d("Dispatch action: $action")
// printOnboardingWalletState()
}
nextDispatch(action)
}

View file

@ -14,6 +14,7 @@ import com.tangem.tap.common.redux.ErrorAction
import com.tangem.tap.common.redux.NotificationAction
import com.tangem.tap.common.redux.StateDialog
import com.tangem.tap.common.redux.ToastNotificationAction
import com.tangem.tap.common.zendesk.ZendeskConfig
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.configurable.config.ConfigManager
import com.tangem.tap.domain.configurable.warningMessage.WarningMessage
@ -36,9 +37,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(
@ -78,7 +80,7 @@ sealed class GlobalAction : Action {
data class SetAnanlyticHandlers(val analyticsHandlers: GlobalAnalyticsHandler) : GlobalAction()
data class SendEmail(val feedbackData: FeedbackData) : GlobalAction()
data class OpenChat(val feedbackData: FeedbackData) : GlobalAction()
data class OpenChat(val feedbackData: FeedbackData, val zendeskConfig: ZendeskConfig? = null) : GlobalAction()
data class UpdateFeedbackInfo(val walletManagers: List<WalletManager>) : GlobalAction()
object ExchangeManager : GlobalAction() {

View file

@ -87,7 +87,31 @@ private fun handleAction(action: Action, appState: () -> AppState?, dispatch: Di
store.state.globalState.feedbackManager?.sendEmail(action.feedbackData)
}
is GlobalAction.OpenChat -> {
store.state.globalState.feedbackManager?.openChat(action.feedbackData)
val globalState = store.state.globalState
val feedbackManager = globalState.feedbackManager.guard {
store.dispatchDebugErrorNotification("FeedbackManager not initialized")
return
}
val config = globalState.configManager?.config.guard {
store.dispatchDebugErrorNotification("Config not initialized")
return
}
val scanResponse = globalState.onboardingState.onboardingManager?.scanResponse
?: globalState.scanResponse
// if config not set -> try to get it based on a scanResponse.productType
val unsafeZendeskConfig = action.zendeskConfig ?: when {
scanResponse?.isSaltPay() == true -> config.saltPayConfig?.zendesk
else -> config.zendesk
}
val zendeskConfig = unsafeZendeskConfig.guard {
store.dispatchDebugErrorNotification("ZendeskConfig not initialized")
return
}
feedbackManager.initChat(zendeskConfig)
feedbackManager.openChat(action.feedbackData)
}
is GlobalAction.UpdateWalletSignedHashes -> {
store.dispatch(WalletAction.Warnings.CheckRemainingSignatures(action.remainingSignatures))

View file

@ -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))
}

View file

@ -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()
}
}

View file

@ -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
@ -59,32 +60,32 @@ class TangemSdkManager(private val tangemSdk: TangemSdk, private val context: Co
}
suspend fun createProductWallet(
scanResponse: ScanResponse
scanResponse: ScanResponse,
): CompletionResult<CreateProductWalletTaskResponse> {
return runTaskAsync(
CreateProductWalletTask(scanResponse.productType),
scanResponse.card.cardId,
Message(context.getString(R.string.initial_message_create_wallet_body))
Message(context.getString(R.string.initial_message_create_wallet_body)),
)
}
private fun sendScanResultsToAnalytics(
analyticsHandler: AnalyticsHandler?,
result: CompletionResult<ScanResponse>
result: CompletionResult<ScanResponse>,
) {
when (result) {
is CompletionResult.Success -> {
analyticsHandler?.triggerEvent(
event = AnalyticsEvent.CARD_IS_SCANNED,
card = result.data.card,
blockchain = result.data.walletData?.blockchain
blockchain = result.data.walletData?.blockchain,
)
if (DemoHelper.isDemoCard(result.data)) {
analyticsHandler?.triggerEvent(
event = AnalyticsEvent.DEMO_MODE_ACTIVATED,
card = result.data.card,
blockchain = result.data.walletData?.blockchain,
params = mapOf(AnalyticsParam.CARD_ID.param to result.data.card.cardId)
params = mapOf(AnalyticsParam.CARD_ID.param to result.data.card.cardId),
)
}
}
@ -92,20 +93,22 @@ class TangemSdkManager(private val tangemSdk: TangemSdk, private val context: Co
(result.error as? TangemSdkError)?.let { error ->
analyticsHandler?.logCardSdkError(
error = error,
actionToLog = Analytics.ActionToLog.Scan
actionToLog = Analytics.ActionToLog.Scan,
)
}
}
}
suspend fun createWallet(cardId: String?): CompletionResult<Card> {
return runTaskAsyncReturnOnMain(CreateWalletAndRescanTask(), cardId,
initialMessage = Message(context.getString(R.string.initial_message_create_wallet_body)))
return runTaskAsyncReturnOnMain(
CreateWalletAndRescanTask(), cardId,
initialMessage = Message(context.getString(R.string.initial_message_create_wallet_body)),
)
}
suspend fun derivePublicKeys(
cardId: String,
derivations: Map<ByteArrayKey, List<DerivationPath>>
derivations: Map<ByteArrayKey, List<DerivationPath>>,
): CompletionResult<DerivationTaskResponse> {
return runTaskAsyncReturnOnMain(DeriveMultipleWalletPublicKeysTask(derivations), cardId)
}
@ -114,49 +117,58 @@ class TangemSdkManager(private val tangemSdk: TangemSdk, private val context: Co
return runTaskAsyncReturnOnMain(
ResetToFactorySettingsTask(),
card.cardId,
initialMessage = Message(context.getString(R.string.details_row_title_reset_factory_settings)))
initialMessage = Message(context.getString(R.string.details_row_title_reset_factory_settings)),
)
}
suspend fun setPasscode(cardId: String?): CompletionResult<SuccessResponse> {
return runTaskAsyncReturnOnMain(
SetUserCodeCommand.changePasscode(null),
cardId,
initialMessage = Message(context.getString(R.string.initial_message_change_passcode_body)))
initialMessage = Message(context.getString(R.string.initial_message_change_passcode_body)),
)
}
suspend fun setAccessCode(cardId: String?): CompletionResult<SuccessResponse> {
return runTaskAsyncReturnOnMain(
SetUserCodeCommand.changeAccessCode(null),
cardId,
initialMessage = Message(context.getString(R.string.initial_message_change_access_code_body)))
initialMessage = Message(context.getString(R.string.initial_message_change_access_code_body)),
)
}
suspend fun setLongTap(cardId: String?): CompletionResult<SuccessResponse> {
return runTaskAsyncReturnOnMain(
SetUserCodeCommand.resetUserCodes(),
cardId,
initialMessage = Message(context.getString(R.string.initial_message_tap_header)))
initialMessage = Message(context.getString(R.string.initial_message_tap_header)),
)
}
suspend fun checkUserCodes(cardId: String?): CompletionResult<CheckUserCodesResponse> {
return runTaskAsyncReturnOnMain(
CheckUserCodesCommand(),
cardId,
initialMessage = Message(context.getString(R.string.initial_message_tap_header)))
initialMessage = Message(context.getString(R.string.initial_message_tap_header)),
)
}
suspend fun scanCard(): CompletionResult<Card> {
return runTaskAsyncReturnOnMain(
ScanTask(),
initialMessage = Message(context.getString(R.string.initial_message_tap_header)))
initialMessage = Message(context.getString(R.string.initial_message_tap_header)),
)
}
suspend fun <T : CommandResponse> runTaskAsync(
runnable: CardSessionRunnable<T>, cardId: String? = null, initialMessage: Message? = null,
runnable: CardSessionRunnable<T>,
cardId: String? = null,
initialMessage: Message? = null,
accessCode: String? = null,
): CompletionResult<T> =
withContext(Dispatchers.Main) {
suspendCoroutine { continuation ->
tangemSdk.startSessionWithRunnable(runnable, cardId, initialMessage) { result ->
tangemSdk.startSessionWithRunnable(runnable, cardId, initialMessage, accessCode) { result ->
if (continuation.context.isActive) continuation.resume(result)
}
}
@ -170,20 +182,24 @@ class TangemSdkManager(private val tangemSdk: TangemSdk, private val context: Co
}
fun changeDisplayedCardIdNumbersCount(scanResponse: ScanResponse) {
tangemSdk.config.cardIdDisplayFormat = if (scanResponse.isTangemTwins()) {
CardIdDisplayFormat.LastLuhn(4)
} else {
CardIdDisplayFormat.Full
tangemSdk.config.cardIdDisplayFormat = when {
scanResponse.isTangemTwins() -> CardIdDisplayFormat.LastLuhn(4)
scanResponse.isSaltPay() -> CardIdDisplayFormat.None
else -> CardIdDisplayFormat.Full
}
}
fun getString(@StringRes stringResId: Int, vararg formatArgs: Any?): String {
return context.getString(stringResId, formatArgs)
}
companion object {
val config = Config(
linkedTerminal = true,
allowUntrustedCards = true,
filter = CardFilter(
allowedCardTypes = FirmwareVersion.FirmwareType.values().toList()
)
allowedCardTypes = FirmwareVersion.FirmwareType.values().toList(),
),
)
}
}

View file

@ -70,6 +70,7 @@ sealed class TapSdkError(override val messageResId: Int?) : Throwable(), TangemE
object CardForDifferentApp : TapSdkError(R.string.alert_unsupported_card)
object CardNotSupportedByRelease : TapSdkError(R.string.error_update_app)
object ScanPrimaryCard : TapSdkError(R.string.saltpay_backup_warning)
}
fun TapErrors.assembleErrors(): MutableList<Pair<Int, List<Any>?>> {

View file

@ -35,9 +35,11 @@ class TapWalletManager {
val walletManagerFactory: WalletManagerFactory
by lazy { WalletManagerFactory(blockchainSdkConfig) }
val rates: RatesRepository = RatesRepository()
private val blockchainSdkConfig by lazy {
store.state.globalState.configManager?.config?.blockchainSdkConfig ?: BlockchainSdkConfig()
}
private val walletManagersThrottler =
ThrottlerWithValues<BlockchainNetwork, Result<Wallet>>(10000)
@ -134,9 +136,7 @@ class TapWalletManager {
dispatchOnMain(WalletAction.LoadWallet())
}
private suspend fun loadMultiWalletData(
scanResponse: ScanResponse,
) {
private suspend fun loadMultiWalletData(scanResponse: ScanResponse) {
loadUserCurrencies(scanResponse, walletManagerFactory)
}

View file

@ -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,
)
}

View file

@ -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<String>?,
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<String>?,
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?) {

View file

@ -6,6 +6,7 @@ import com.tangem.common.card.EllipticCurve
import com.tangem.common.card.FirmwareVersion
import com.tangem.common.extensions.toHexString
import com.tangem.common.services.Result
import com.tangem.domain.common.TapWorkarounds.isSaltPay
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.common.TapWorkarounds.isTangemNote
import com.tangem.domain.common.TwinCardNumber
@ -23,11 +24,14 @@ val Card.isWalletDataSupported: Boolean
val Card.isMultiwalletAllowed: Boolean
get() {
return !isTangemTwin() && !isStart2Coin && !isTangemNote()
return !isTangemTwin() && !isStart2Coin && !isTangemNote && !isSaltPay
&& (firmwareVersion >= FirmwareVersion.MultiWalletAvailable ||
getSingleWallet()?.curve == EllipticCurve.Secp256k1)
}
val Card.isHdWalletAllowedByApp: Boolean
get() = settings.isHDWalletAllowed && !isSaltPay
fun Card.getSingleWallet(): CardWallet? {
return wallets.firstOrNull()
}

View file

@ -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
@ -39,10 +43,10 @@ fun WalletManagerFactory.makeWalletManagerForApp(
walletPublicKey = wallet.publicKey,
pairPublicKey = scanResponse.secondTwinPublicKey!!.hexToBytes(),
blockchain = environmentBlockchain,
curve = wallet.curve
curve = wallet.curve,
)
}
seedKey != null && derivationParams != null -> {
scanResponse.card.isHdWalletAllowedByApp && (seedKey != null && derivationParams != null) -> {
val derivedKeys = scanResponse.derivedKeys[wallet.publicKey.toMapKey()]
val derivationPath = when (derivationParams) {
is DerivationParams.Default -> blockchain.derivationPath(derivationParams.style)
@ -55,33 +59,33 @@ fun WalletManagerFactory.makeWalletManagerForApp(
blockchain = environmentBlockchain,
seedKey = wallet.publicKey,
derivedKey = derivedKey,
derivation = derivationParams
derivation = derivationParams,
)
}
else -> {
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<Currency>,
scanResponse: ScanResponse,
blockchains: List<Currency>,
): List<WalletManager> {
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>): 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
}

View file

@ -0,0 +1,32 @@
package com.tangem.tap.domain.moduleMessage
/**
[REDACTED_AUTHOR]
* Base interface for conversion outputs from ModuleMessageConverter
*/
interface ConvertedMessage {
val message: String
}
/**
* Dialog message used to construct a android.Dialog
*/
interface DialogMessage : ConvertedMessage {
val title: String
override val message: String
val onPositive: String?
val onNegative: String?
val onNeutral: String?
}
data class ConvertedStringMessage(
override val message: String,
) : ConvertedMessage
data class ConvertedDialogMessage(
override val title: String,
override val message: String,
override val onPositive: String? = null,
override val onNegative: String? = null,
override val onNeutral: String? = null,
) : DialogMessage

View file

@ -0,0 +1,27 @@
package com.tangem.tap.domain.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.domain.moduleMessage.domain.DomainMessageConverter
import com.tangem.tap.domain.moduleMessage.saltPay.SaltPayMessageConverter
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayModuleMessage
class ModuleMessageConverter(
private val context: Context,
) : ModuleMessageConverter<ModuleMessage, ConvertedMessage> {
override fun convert(message: ModuleMessage): ConvertedMessage {
val convertedMessage = when (message) {
is DomainModuleMessage -> DomainMessageConverter(context).convert(message)
is SaltPayModuleMessage -> SaltPayMessageConverter(context).convert(message)
else -> null
}
return convertedMessage ?: convertUnknownMessage(message)
}
private fun convertUnknownMessage(message: ModuleMessage): ConvertedMessage {
return ConvertedStringMessage("Unknown message: ${message::class.java.simpleName}")
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.tap.domain.moduleMessage.domain
import android.content.Context
import com.tangem.common.module.ModuleMessageConverter
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.DomainModuleError
import com.tangem.domain.DomainModuleMessage
import com.tangem.tap.domain.moduleMessage.ConvertedMessage
import com.tangem.tap.domain.moduleMessage.domain.converter.AddCustomTokenErrorConverter
/**
[REDACTED_AUTHOR]
*/
class DomainMessageConverter(
private val context: Context,
) : ModuleMessageConverter<DomainModuleMessage, ConvertedMessage?> {
override fun convert(message: DomainModuleMessage): ConvertedMessage? {
return when (message) {
is DomainModuleError -> DomainErrorConverter(context).convert(message)
else -> null
}
}
}
class DomainErrorConverter(
private val context: Context,
) : ModuleMessageConverter<DomainModuleError, ConvertedMessage?> {
override fun convert(message: DomainModuleError): ConvertedMessage? = when (message) {
is AddCustomTokenError -> AddCustomTokenErrorConverter(context).convert(message)
else -> null
}
}

View file

@ -1,28 +1,21 @@
package com.tangem.tap.common.moduleMessage.domain
package com.tangem.tap.domain.moduleMessage.domain.converter
import android.content.Context
import com.tangem.common.module.ModuleMessageConverter
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.DomainError
import com.tangem.domain.DomainModuleError
import com.tangem.tap.domain.moduleMessage.ConvertedMessage
import com.tangem.tap.domain.moduleMessage.ConvertedStringMessage
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
class DomainErrorConverter(
private val context: Context
) : ModuleMessageConverter<DomainError, String?> {
override fun convert(message: DomainError): String? = when (message) {
is AddCustomTokenError -> AddCustomTokenConverter(context).convert(message)
else -> null
}
}
internal class AddCustomTokenErrorConverter(
private val context: Context,
) : ModuleMessageConverter<DomainModuleError, ConvertedMessage?> {
private class AddCustomTokenConverter(
private val context: Context
) : ModuleMessageConverter<DomainError, String?> {
override fun convert(message: DomainError): String? {
override fun convert(message: DomainModuleError): ConvertedMessage? {
val customTokenError = (message as? AddCustomTokenError) ?: throw UnsupportedOperationException()
val rawMessage = when (customTokenError) {
@ -40,8 +33,8 @@ private class AddCustomTokenConverter(
}
return when (rawMessage) {
is Int -> context.getString(rawMessage)
is String -> rawMessage
is Int -> ConvertedStringMessage(context.getString(rawMessage))
is String -> ConvertedStringMessage(rawMessage)
else -> null
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.tap.domain.moduleMessage.saltPay
import android.content.Context
import com.tangem.common.module.ModuleMessageConverter
import com.tangem.tap.domain.moduleMessage.ConvertedMessage
import com.tangem.tap.domain.moduleMessage.saltPay.converter.SaltPayRegistrationErrorConverter
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayModuleError
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayModuleMessage
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayRegistrationError
/**
[REDACTED_AUTHOR]
*/
class SaltPayMessageConverter(
private val context: Context,
) : ModuleMessageConverter<SaltPayModuleMessage, ConvertedMessage?> {
override fun convert(message: SaltPayModuleMessage): ConvertedMessage? {
return when (message) {
is SaltPayModuleError -> SaltPayErrorConverter(context).convert(message)
else -> null
}
}
}
class SaltPayErrorConverter(
private val context: Context,
) : ModuleMessageConverter<SaltPayModuleError, ConvertedMessage?> {
override fun convert(message: SaltPayModuleError): ConvertedMessage? = when (message) {
is SaltPayRegistrationError -> SaltPayRegistrationErrorConverter(context).convert(message)
else -> null
}
}

View file

@ -0,0 +1,53 @@
package com.tangem.tap.domain.moduleMessage.saltPay.converter
import android.content.Context
import com.tangem.common.module.ModuleMessageConverter
import com.tangem.tap.domain.moduleMessage.ConvertedDialogMessage
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayRegistrationError
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
internal class SaltPayRegistrationErrorConverter(
private val context: Context,
) : ModuleMessageConverter<SaltPayRegistrationError, ConvertedDialogMessage?> {
override fun convert(message: SaltPayRegistrationError): ConvertedDialogMessage? {
val saltPayError = (message as? SaltPayRegistrationError) ?: throw UnsupportedOperationException()
val dialogMessage = when (saltPayError) {
SaltPayRegistrationError.NoGas -> {
ConvertedDialogMessage(
title = context.getString(R.string.saltpay_error_no_gas_title),
message = context.getString(R.string.saltpay_error_no_gas_message),
)
}
SaltPayRegistrationError.EmptyBackupCardScanned -> {
ConvertedDialogMessage(
title = context.getString(R.string.saltpay_error_empty_backup_title),
message = context.getString(R.string.saltpay_error_empty_backup_message),
)
}
SaltPayRegistrationError.WeakPin -> {
ConvertedDialogMessage(
title = context.getString(R.string.saltpay_error_pin_weak_title),
message = context.getString(R.string.saltpay_error_pin_weak_message),
)
}
else -> {
val errorMessage = if (saltPayError.message == SaltPayRegistrationError.EMPTY_MESSAGE) {
saltPayError::class.java.simpleName
} else {
saltPayError.message
}
ConvertedDialogMessage(
title = context.getString(R.string.common_error),
message = errorMessage,
)
}
}
return dialogMessage
}
}

View file

@ -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<AppState> {
private val converter = MoshiJsonConverter(
MoshiJsonConverter.getTangemSdkAdapters() + internalAdapters(),
MoshiJsonConverter.getTangemSdkTypedAdapters(),
)
private fun internalAdapters(): List<Any> {
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)
}
}

View file

@ -4,7 +4,6 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.common.CompletionResult
import com.tangem.common.card.Card
import com.tangem.common.card.EllipticCurve
import com.tangem.common.card.FirmwareVersion
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.TangemError
@ -18,10 +17,10 @@ import com.tangem.domain.common.ProductType
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.isTangemNote
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
@ -32,6 +31,7 @@ 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.extensions.isMultiwalletAllowed
import com.tangem.tap.domain.tokens.UserTokensRepository
import com.tangem.tap.domain.tokens.models.BlockchainNetwork
import com.tangem.tap.preferencesStorage
@ -58,8 +58,7 @@ class ScanProductTask(
}
val commandProcessor = when {
card.isTangemNote() -> ScanNoteProcessor()
card.isTangemTwins() -> ScanTwinProcessor()
card.isTangemTwins -> ScanTwinProcessor()
else -> ScanWalletProcessor(userTokensRepository, additionalBlockchainsToDerive)
}
commandProcessor.proceed(card, session) { processorResult ->
@ -83,31 +82,12 @@ 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
return null
}
}
private class ScanNoteProcessor : ProductCommandProcessor<ScanResponse> {
override fun proceed(
card: Card,
session: CardSession,
callback: (result: CompletionResult<ScanResponse>) -> Unit
) {
callback(
CompletionResult.Success(
ScanResponse(
card = card,
productType = ProductType.Note,
walletData = session.environment.walletData,
),
),
)
}
}
private class ScanWalletProcessor(
private val userTokensRepository: UserTokensRepository?,
private val additionalBlockchainsToDerive: Collection<Blockchain>? = null,
@ -121,12 +101,14 @@ private class ScanWalletProcessor(
) {
createMissingWalletsIfNeeded(card, session, callback)
}
private fun createMissingWalletsIfNeeded(
card: Card,
session: CardSession,
callback: (result: CompletionResult<ScanResponse>) -> Unit,
) {
if (card.wallets.isEmpty() || card.firmwareVersion < FirmwareVersion.MultiWalletAvailable) {
if (card.wallets.isEmpty() || !card.isMultiwalletAllowed) {
// match for the saltPay card to
startLinkingForBackupIfNeeded(card, session, callback)
return
}
@ -182,13 +164,17 @@ private class ScanWalletProcessor(
session: CardSession,
callback: (result: CompletionResult<ScanResponse>) -> 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,
),
@ -202,7 +188,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,
@ -256,6 +242,7 @@ private class ScanWalletProcessor(
val curve = blockchain.blockchain.getPrimaryCurve()
val wallet = card.wallets.firstOrNull { it.curve == curve } ?: return@forEach
if (wallet.chainCode == null) return@forEach
val key = wallet.publicKey.toMapKey()
val path = blockchain.derivationPath?.let { DerivationPath(it) }
if (path != null) {

View file

@ -106,17 +106,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"
const val SORT_DEFAULT_VALUE = "manual"
const val GROUP_DEFAULT_VALUE = "none"
fun init(context: Context, tangemTechService: TangemTechService): UserTokensRepository {
@ -129,4 +122,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()
}
}

View file

@ -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 {

View file

@ -79,7 +79,7 @@ private fun prepareSecurityOptions(card: Card): ManageSecurityState {
}
}
val allowedSecurityOptions = when {
card.isStart2Coin || card.isTangemNote() -> {
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
}

View file

@ -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<AppState>) {
},
)
} 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<AppState>) {
CardInfo.Issuer(state.cardInfo.issuer),
)
if (!state.card.isTangemTwins()) {
if (!state.card.isTangemTwins) {
cardDetails.add(CardInfo.SignedHashes(state.cardInfo.signedHashes.toString()))
}
cardDetails.add(

View file

@ -1,5 +1,6 @@
package com.tangem.tap.features.details.ui.details
import com.tangem.domain.common.TapWorkarounds.isSaltPay
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
import com.tangem.tap.common.feedback.FeedbackEmail
import com.tangem.tap.common.feedback.SupportInfo
@ -25,7 +26,9 @@ class DetailsViewModel(private val store: Store<AppState>) {
if (state.scanResponse?.card?.isMultiwalletAllowed == true) it else null
}
SettingsElement.LinkMoreCards -> {
if (state.createBackupAllowed) it else null
// if (state.createBackupAllowed) it else null
// TODO: SaltPay: temporary excluding backup process for Visa cards
if (state.createBackupAllowed && state.scanResponse?.card?.isSaltPay != true) it else null
}
SettingsElement.PrivacyPolicy -> {
if (state.privacyPolicyUrl != null) it else null

View file

@ -6,6 +6,7 @@ import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
import androidx.transition.TransitionInflater
import by.kirich1409.viewbindingdelegate.viewBinding
import com.tangem.tap.common.extensions.configureSettings
import com.tangem.tap.common.extensions.hide
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.features.disclaimer.redux.DisclaimerAction
@ -16,17 +17,20 @@ import com.tangem.wallet.databinding.FragmentDisclaimerBinding
import org.rekotlin.StoreSubscriber
class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer),
StoreSubscriber<DisclaimerState> {
StoreSubscriber<DisclaimerState> {
private val binding: FragmentDisclaimerBinding by viewBinding(FragmentDisclaimerBinding::bind)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
activity?.onBackPressedDispatcher?.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
store.dispatch(NavigationAction.PopBackTo())
}
})
activity?.onBackPressedDispatcher?.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
store.dispatch(NavigationAction.PopBackTo())
}
},
)
val inflater = TransitionInflater.from(requireContext())
enterTransition = inflater.inflateTransition(android.R.transition.slide_bottom)
exitTransition = inflater.inflateTransition(android.R.transition.slide_top)
@ -51,9 +55,15 @@ class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer),
binding.toolbar.setNavigationOnClickListener {
store.dispatch(NavigationAction.PopBackTo())
}
initAndLoadTOS()
setOnClickListeners()
}
private fun initAndLoadTOS() {
binding.webView.configureSettings()
binding.webView.loadUrl("file:///android_asset/tos.html")
}
private fun setOnClickListeners() {
binding.btnAccept.setOnClickListener { store.dispatch(DisclaimerAction.AcceptDisclaimer) }
}

View file

@ -81,16 +81,12 @@ class HomeFragment : Fragment(), StoreSubscriber<HomeState> {
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())
}
},
)
}

View file

@ -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,
)
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}

View file

@ -1,15 +1,21 @@
package com.tangem.tap.features.home.redux
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.AppDialog
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.common.redux.navigation.AppScreen
@ -19,13 +25,22 @@ 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 com.tangem.wallet.R
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.rekotlin.Action
import org.rekotlin.DispatchFunction
import org.rekotlin.Middleware
class HomeMiddleware {
@ -36,87 +51,146 @@ class HomeMiddleware {
}
}
private val homeMiddleware: Middleware<AppState> = { _, _ ->
private val homeMiddleware: Middleware<AppState> = { 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))
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()) {
if (scanResponse.isSaltPayVisa()) {
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 (scanResponse.card.backupStatus?.isActive == false) {
changeButtonState(ButtonState.ENABLED)
store.dispatchDialogShow(
AppDialog.SimpleOkDialogRes(
headerId = R.string.saltpay_error_empty_backup_title,
messageId = R.string.saltpay_error_empty_backup_message,
),
)
} else {
navigateTo(AppScreen.Wallet, null)
scope.launch { store.onCardScanned(scanResponse) }
}
}
} 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))
}

View file

@ -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)
}

View file

@ -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
) : StateType {
val btnScanStateInProgress: Boolean
get() = btnScanState.progressState == ProgressState.Loading
}

View file

@ -15,16 +15,14 @@ class OnboardingHelper {
fun isOnboardingCase(response: ScanResponse): Boolean {
val cardInfoStorage = preferencesStorage.usedCardsPrefStorage
return when {
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
}
}
@ -38,6 +36,7 @@ class OnboardingHelper {
AppScreen.OnboardingOther
}
ProductType.Twins -> AppScreen.OnboardingTwins
ProductType.SaltPay -> AppScreen.OnboardingWallet
}
}
}

View file

@ -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<Boolean> {
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)
}
}
}
}

View file

@ -49,7 +49,7 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
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<OnboardingNoteState>() {
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)
}
}
}

View file

@ -116,10 +116,10 @@ class OnboardingOtherCardsFragment : BaseOnboardingFragment<OnboardingOtherCards
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)
}
}
}

View file

@ -79,7 +79,7 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
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<TwinCardsState>() {
chbUnderstand.hide()
pbBinding.pbState.show()
binding.onboardingTopContainer.onboardingMainContainer.beginDelayedTransition()
binding.onboardingTopContainer.onboardingWalletContainer.beginDelayedTransition()
when (previousStep) {
TwinCardsStep.None -> {
@ -383,10 +383,10 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
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)
}
}

View file

@ -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()
object ProceedBackup : OnboardingWalletAction()
object ResumeBackup : OnboardingWalletAction()
object LoadArtwork : OnboardingWalletAction()
class SetArtworkUrl(val artworkUrl: String?) : OnboardingWalletAction()
@ -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()
}

View file

@ -1,12 +1,15 @@
package com.tangem.tap.features.onboarding.products.wallet.redux
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.extensions.withMainContext
import com.tangem.operations.backup.BackupService
import com.tangem.tap.*
import com.tangem.tap.backupService
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
@ -16,10 +19,18 @@ 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
import com.tangem.tap.scope
import com.tangem.tap.store
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 {
@ -31,35 +42,57 @@ class OnboardingWalletMiddleware {
private val onboardingWalletMiddleware: Middleware<AppState> = { 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.hasWallets() && card.backupStatus?.isActive == true ->
BackupAction.FinishBackup
else -> OnboardingWalletAction.GetToCreateWalletStep
}
store.dispatch(action)
}
is OnboardingWalletAction.LoadArtwork -> {
scope.launch {
@ -80,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
@ -110,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)
@ -120,15 +153,19 @@ private fun handleWalletAction(action: Action) {
store.dispatchOnMain(NavigationAction.NavigateTo(AppScreen.Wallet))
}
}
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
if (onboardingWalletState.isSaltPay) {
BackupAction.StartBackup
} else {
BackupAction.IntroduceBackup
}
} else {
null
}
@ -137,7 +174,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,
@ -159,7 +196,7 @@ private fun updateScanResponseAfterBackup(
val cardsCount = backupState.backupCardsNumber
scanResponse.card.copy(
backupStatus = Card.BackupStatus.Active(cardCount = cardsCount),
isAccessCodeSet = true
isAccessCodeSet = true,
)
} else {
scanResponse.card
@ -180,13 +217,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) {
@ -212,32 +247,34 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
}
}
is BackupAction.AddBackupCard -> {
backupService.skipCompatibilityChecks = true
tangemSdk.config.filter.cardIdFilter = CardFilter.Companion.CardIdFilter.Allow(
items = SaltPayWorkaround.walletCardIds.toSet(),
ranges = SaltPayWorkaround.walletCardIdRanges,
)
backupService.addBackupCard { result ->
backupService.skipCompatibilityChecks = false
tangemSdk.config.filter.cardIdFilter = null
when (result) {
is CompletionResult.Success -> {
store.dispatchOnMain(BackupAction.AddBackupCard.Success)
}
is CompletionResult.Failure -> {
}
}
}
}
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))
@ -247,22 +284,17 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
if (action.accessCodeConfirmation == backupState.accessCode) {
store.dispatch(BackupAction.SetAccessCodeError(null))
backupService.setAccessCode(action.accessCodeConfirmation)
store.dispatch(OnboardingSaltPayAction.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 -> {
}
@ -274,15 +306,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 -> {
}
}
}
@ -308,13 +338,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)
}
}

View file

@ -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,17 +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,
maxBackupCards = if (isSaltPay) 1 else 2,
canSkipBackup = !isSaltPay,
)
BackupAction.StartAddingPrimaryCard -> state.copy(backupStep = BackupStep.ScanOriginCard)
BackupAction.StartAddingBackupCards -> {
@ -50,9 +83,6 @@ class BackupReducer {
BackupAction.AddBackupCard.Success -> {
state.copy(backupCardsNumber = state.backupCardsNumber + 1)
}
BackupAction.FinishAddingBackupCards -> {
state
}
BackupAction.ShowAccessCodeInfoScreen -> {
state.copy(backupStep = BackupStep.SetAccessCode)
}
@ -62,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) {
@ -76,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 -> {
@ -90,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
}
}
}
}

View file

@ -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,23 +36,35 @@ 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.KycIntro -> 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<CardId> = emptyList(),
@Transient
val backupCardsArtworks: Map<CardId, Bitmap> = emptyMap(),
val accessCode: String = "",
val accessCodeError: AccessCodeError? = null,
@ -72,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

View file

@ -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<Boolean> {
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<Unit> {
walletManager.safeUpdate().successOr {
return Result.Failure(BlockchainSdkError.WrappedThrowable(it.error))
}
atomicNonce.set(walletManager.txCount)
return Result.Success(Unit)
}
suspend fun sendTransactions(transactions: List<SignedEthereumTransaction>): Result<List<String>> {
val results = transactions.map {
coroutineScope { async { walletManager.sendRaw(it.compiledTransaction, it.signature) } }
}.awaitAll()
val errors = results.filterIsInstance<SimpleResult.Failure>()
return if (errors.isEmpty()) {
Result.Success(listOf())
} else {
Result.Failure(errors[0].error)
}
}
suspend fun makeSetSpendLimitTx(value: BigDecimal): Result<CompiledEthereumTransaction> {
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<CompiledEthereumTransaction> {
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<CompiledEthereumTransaction> {
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<CompiledEthereumTransaction> {
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<List<Amount>>.extractFeeAmount(): Result<Amount> {
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())
}
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay
import com.tangem.common.Filter
/**
[REDACTED_AUTHOR]
*/
abstract class PinFilter : Filter<String>
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 }
}
}

View file

@ -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,
)

View file

@ -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.valueOf(Math.pow(2.toDouble(), 255.toDouble()))
private val spendLimitValue: BigDecimal = BigDecimal("100")
fun transactionIsSent(): Boolean {
return registrationStorage.data.transactionsSent
}
suspend fun checkHasGas(): Result<Unit> {
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<Unit> {
return when (val result = paymentologyService.registerKYC(makeRegisterKYCRequest())) {
is Result.Success -> Result.Success(Unit)
is Result.Failure -> result
}
}
suspend fun checkRegistration(): Result<RegistrationResponse.Item> {
val registrationResponse = paymentologyService.checkRegistration(cardId, cardPublicKey).successOr { return it }
return try {
if (!registrationResponse.success || registrationResponse.results.isEmpty()) {
throw SaltPayRegistrationError.EmptyResponse(registrationResponse.error)
}
Result.Success(registrationResponse.results[0])
} catch (ex: SaltPayRegistrationError) {
Result.Failure(ex)
}
}
suspend fun requestAttestationChallenge(): Result<AttestationResponse> {
return paymentologyService.requestAttestationChallenge(cardId, cardPublicKey)
}
suspend fun sendTransactions(
signedTransactions: List<SignedEthereumTransaction>,
): com.tangem.blockchain.extensions.Result<List<String>> {
return gnosisRegistrator.sendTransactions(signedTransactions)
}
suspend fun registerWallet(
attestResponse: AttestWalletKeyResponse,
pinCode: String,
): Result<RegisterWalletResponse> {
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()
}
}

View file

@ -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<SignedEthereumTransaction>,
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<RegistrationTaskResponse> {
private var generateOTPResponse: GenerateOTPResponse? = null
private var attestWalletResponse: AttestWalletKeyResponse? = null
private var signedTransactions: List<SignedEthereumTransaction> = listOf()
override fun run(session: CardSession, callback: CompletionCallback<RegistrationTaskResponse>) {
generateOTP(session, callback)
}
private fun generateOTP(session: CardSession, callback: CompletionCallback<RegistrationTaskResponse>) {
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<RegistrationTaskResponse>,
) {
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<RegistrationTaskResponse>,
) {
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<CompiledEthereumTransaction>,
session: CardSession,
callback: CompletionCallback<RegistrationTaskResponse>,
) {
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<RegistrationTaskResponse>) {
if (signedTransactions.isEmpty() || attestWalletResponse == null) {
callback(CompletionResult.Failure(TangemSdkError.UnknownError()))
} else {
val result = RegistrationTaskResponse(signedTransactions, attestWalletResponse!!)
callback(CompletionResult.Success(result))
}
}
}

View file

@ -0,0 +1,166 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import android.text.TextUtils
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.core.app.ActivityCompat
import com.tangem.tap.common.ActivityResultCallbackHolder
import com.tangem.tap.common.OnActivityResultCallback
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.extensions.isPermissionGranted
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.store
import com.tangem.wallet.R
import java.io.File
/**
[REDACTED_AUTHOR]
*/
class UtorgWebViewClient(
private val onSuccess: () -> Unit,
private val successUrl: String,
) : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
if (successUrl == request.url.toString()) {
onSuccess()
true
} else {
false
}
}
class UtorgWebChromeClient(
private val activity: ComponentActivity,
private val activityResultCallbackHolder: ActivityResultCallbackHolder = activity as ActivityResultCallbackHolder,
) : WebChromeClient() {
private val fileChooseRequestCode = 1002
private val permissionRequestCode = 1003
private val onActivityResultCallback: OnActivityResultCallback = ::onActivityResult
private val permissionList = listOf(
"android.permission.CAMERA",
)
private var filePathCallback: ValueCallback<Array<Uri>>? = null
private var mediaUri: Uri? = null
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>,
fileChooserParams: FileChooserParams?,
): Boolean {
activityResultCallbackHolder.addOnActivityResultCallback(onActivityResultCallback)
if (!readyToShowFileChooser()) return false
val type: String
if (fileChooserParams != null && fileChooserParams.acceptTypes != null && fileChooserParams.acceptTypes.isNotEmpty()) {
type = if (!TextUtils.isEmpty(fileChooserParams.acceptTypes[0])) fileChooserParams.acceptTypes[0] else "*/*"
this.filePathCallback = filePathCallback
} else {
return false
}
proceedOnType(type, fileChooserParams.isCaptureEnabled)
return true
}
private fun readyToShowFileChooser(): Boolean {
val permissionsToGrant = permissionList.filterNot { activity.isPermissionGranted(it) }.toTypedArray()
return if (permissionsToGrant.isNotEmpty()) {
ActivityCompat.requestPermissions(activity, permissionsToGrant, permissionRequestCode)
false
} else {
true
}
}
private fun proceedOnType(type: String, isCaptureEnabled: Boolean) {
if (isCaptureEnabled) {
mediaUri = Uri.fromFile(File(getImageCaptureCachePath()))
val imageCaptureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
imageCaptureIntent.putExtra("output", mediaUri)
activity.startActivityForResult(
Intent.createChooser(imageCaptureIntent, "Image Chooser"),
fileChooseRequestCode,
)
} else {
mediaUri = Uri.fromFile(File(getImageCaptureCachePath()))
val getContentIntent = Intent(Intent.ACTION_GET_CONTENT)
getContentIntent.addCategory(Intent.CATEGORY_OPENABLE)
getContentIntent.type = "image/*"
// val imageCaptureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
// imageCaptureIntent.putExtra("output", mediaUri)
val chooserIntent = Intent.createChooser(getContentIntent, "Pick a photo")
// chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(imageCaptureIntent))
activity.startActivityForResult(
Intent.createChooser(chooserIntent, "Image Chooser"),
fileChooseRequestCode,
)
}
}
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
fileChooseRequestCode -> {
if (resultCode != Activity.RESULT_OK) {
filePathCallback?.onReceiveValue(null)
clean()
return
}
if (data == null) {
filePathCallback?.onReceiveValue(arrayOf(mediaUri!!))
} else {
val uri = FileChooserParams.parseResult(resultCode, data)
filePathCallback?.onReceiveValue(uri)
}
clean()
}
permissionRequestCode -> {
val notGranted = permissionList.filterNot { activity.isPermissionGranted(it) }
if (notGranted.isNotEmpty()) {
val permissionsName = notGranted.joinToString("\n") { it.permissionName() }
store.dispatchDialogShow(
AppDialog.SimpleOkDialog(
header = activity.getString(R.string.common_warning),
message = "Permission access is required to pass authorization." +
"To grant permission \n" +
"$permissionsName \n" +
"you must enable them in the app's settings",
),
)
}
}
}
}
override fun onPermissionRequest(request: PermissionRequest) {
request.grant(request.resources)
}
private fun getImageCaptureCachePath(): String = "cached_photo_" + System.currentTimeMillis() + ".jpg"
private fun clean() {
activityResultCallbackHolder.removeOnActivityResultCallback(onActivityResultCallback)
filePathCallback = null
mediaUri = null
}
private fun String.permissionName(): String {
return this.slice(this.lastIndexOf(".") until this.length)
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog
import android.app.Dialog
import android.content.Context
import androidx.appcompat.app.AlertDialog
import com.tangem.common.extensions.guard
import com.tangem.tap.common.extensions.dispatchDebugErrorNotification
import com.tangem.tap.common.feedback.SupportInfo
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.store
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
class NoFundsForActivationDialog {
companion object {
fun create(context: Context): Dialog {
return AlertDialog.Builder(context).apply {
setTitle(R.string.saltpay_error_no_gas_title)
setMessage(R.string.saltpay_error_no_gas_message)
//TODO: SaltPay: change onboarding_supplement_button_kyc_waiting -> to appropriate string
setPositiveButton(R.string.onboarding_supplement_button_kyc_waiting) { _, _ ->
val config = store.state.globalState.configManager?.config?.saltPayConfig?.zendesk.guard {
store.dispatchDebugErrorNotification("SaltPayConfig not initialized")
return@setPositiveButton
}
store.dispatch(GlobalAction.OpenChat(SupportInfo(), config))
}
setNegativeButton(R.string.common_cancel) { _, _ ->
}
setOnDismissListener {
store.dispatch(GlobalAction.HideDialog)
}
setCancelable(false)
}.create()
}
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog
import android.app.Dialog
import android.content.Context
import androidx.appcompat.app.AlertDialog
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.domain.moduleMessage.ConvertedDialogMessage
import com.tangem.tap.domain.moduleMessage.saltPay.SaltPayErrorConverter
import com.tangem.tap.store
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
class RegistrationErrorDialog {
companion object {
fun create(context: Context, dialog: SaltPayDialog.RegistrationError): Dialog {
val convertedMessage = SaltPayErrorConverter(context).convert(dialog.error) as ConvertedDialogMessage
return AlertDialog.Builder(context).apply {
setTitle(convertedMessage.title)
setMessage(convertedMessage.message)
setPositiveButton(R.string.common_ok) { _, _ ->
store.dispatch(GlobalAction.HideDialog)
}
setOnDismissListener {
store.dispatch(GlobalAction.HideDialog)
}
setCancelable(false)
}.create()
}
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog
import com.tangem.tap.common.redux.StateDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayRegistrationError
/**
[REDACTED_AUTHOR]
*/
sealed class SaltPayDialog : StateDialog {
object NoFundsForActivation : SaltPayDialog()
data class RegistrationError(val error: SaltPayRegistrationError) : SaltPayDialog()
}

View file

@ -0,0 +1,51 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.message
import com.tangem.common.module.ModuleError
import com.tangem.common.module.ModuleErrorCode
import com.tangem.common.module.ModuleMessage
sealed interface SaltPayModuleMessage : ModuleMessage
sealed class SaltPayModuleError(
subCode: Int,
override val message: String,
override val data: Any? = null,
) : SaltPayModuleMessage, ModuleError() {
override val code: Int = ModuleErrorCode.SALT_PAY + subCode
companion object {
// base code used for all errors in the module
internal const val ERROR_CODE_REGISTRATION = 100
// const val CODE_ANY_OTHER = 200..299, 300..399 etc
}
}
sealed class SaltPayRegistrationError(
subCode: Int,
message: String? = null,
override val data: Any? = null,
) : SaltPayModuleError(
subCode = ERROR_CODE_REGISTRATION + subCode,
message = message ?: EMPTY_MESSAGE,
) {
object Unknown : SaltPayRegistrationError(4)
object Empty : SaltPayRegistrationError(4)
// gnosis
object FailedToMakeTxData : SaltPayRegistrationError(1)
object FailedToSendTx : SaltPayRegistrationError(2)
object NeedPin : SaltPayRegistrationError(3)
object NoGas : SaltPayRegistrationError(5) //
class EmptyResponse(message: String?) : SaltPayRegistrationError(6, message)
class CardNotFound(message: String?) : SaltPayRegistrationError(8, message)
class CardDisabled(message: String?) : SaltPayRegistrationError(9, message)
class CardNotPassed(message: String?) : SaltPayRegistrationError(10, message)
object EmptyDynamicAttestResponse : SaltPayRegistrationError(11)
object EmptyBackupCardScanned : SaltPayRegistrationError(12) //
object WeakPin : SaltPayRegistrationError(13) //
companion object {
const val EMPTY_MESSAGE = ""
}
}

View file

@ -0,0 +1,33 @@
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: String?) : 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 SetStep(val newStep: SaltPayRegistrationStep? = null) : OnboardingSaltPayAction()
}

View file

@ -0,0 +1,289 @@
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<AppState> = { 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(attestationResponse.error))
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),
),
accessCode = state.accessCode,
).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.KycIntro))
}
}
}
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<Unit> {
if (step == SaltPayRegistrationStep.KycStart ||
step == SaltPayRegistrationStep.KycWaiting ||
step == SaltPayRegistrationStep.Finished
) {
return Result.Success(Unit)
}
return saltPayManager.checkHasGas()
}
private suspend fun registerKYCIfNeeded(state: OnboardingSaltPayState): Result<Unit> {
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<SaltPayRegistrationStep> {
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(this.error)
disabledByAdmin == true -> throw SaltPayRegistrationError.CardDisabled(this.error)
// 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.KycIntro
}
}
@Throws(SaltPayRegistrationError::class)
private fun assertPinValid(pin: String, pinLength: Int) {
val array = pin.toCharArray()
if (array.size < pinLength) throw SaltPayRegistrationError.WeakPin
val filters: List<Filter<String>> = listOf(
AllSymbolsTheSameFilter(),
)
if (filters.any { it.filter(pin) }) {
throw SaltPayRegistrationError.WeakPin
}
}

View file

@ -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)
}
}
}

View file

@ -0,0 +1,118 @@
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.common.extensions.toHexString
import com.tangem.common.json.MoshiJsonConverter
import com.tangem.domain.common.ScanResponse
import com.tangem.network.api.paymentology.PaymentologyApiService
import com.tangem.tap.common.toggleWidget.WidgetState
import com.tangem.tap.copyToClipboard
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: String? = 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<SaltPayRegistrationManager, SaltPayConfig> {
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,
)
test(scanResponse, gnosisRegistrator)
return registrationManager to saltPayConfig
}
//TODO: delete
private fun test(scanResponse: ScanResponse, gnosisRegistrator: GnosisRegistrator) {
val map = mapOf(
"CID" to scanResponse.card.cardId,
"cardPublicKey" to scanResponse.card.cardPublicKey.toHexString(),
"walletPublicKey" to gnosisRegistrator.walletManager.wallet.publicKey.blockchainKey.toHexString(),
"walletAddress" to gnosisRegistrator.walletManager.wallet.address,
)
val json = MoshiJsonConverter.INSTANCE.prettyPrint(map)
copyToClipboard(json)
}
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,
KycIntro,
KycStart,
KycWaiting,
Finished;
}

View file

@ -6,20 +6,37 @@ 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 coil.size.Scale
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.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.NavigationAction
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 +45,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<OnboardingWalletState>, 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 +98,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 +132,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 +153,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,22 +174,21 @@ 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)
is BackupStep.WritePrimaryCard -> showWritePrimaryCard(state)
is BackupStep.WriteBackupCard -> showWriteBackupCard(state)
is BackupStep.WritePrimaryCard -> showWritePrimaryCard(state, isSaltPay)
is BackupStep.WriteBackupCard -> showWriteBackupCard(state, isSaltPay)
BackupStep.Finished -> showSuccess()
}
}
private fun showBackupIntro(state: BackupState) = with(binding) {
private fun showBackupIntro(state: BackupState, isSaltPay: Boolean) = with(binding) {
imvFirstBackupCard.show()
imvSecondBackupCard.show()
@ -177,10 +201,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.text = getText(R.string.onboarding_button_skip_backup)
btnAlternativeAction.setOnClickListener { store.dispatch(BackupAction.DismissBackup) }
btnAlternativeAction.show(state.canSkipBackup && !isSaltPay)
}
startPostponedEnterTransition()
@ -215,7 +240,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()
@ -230,11 +255,20 @@ 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() {
cardsWidget.getFirstBackupCardView().animate().alpha(0.6f).setDuration(200)
cardsWidget.getSecondBackupCardView().animate().alpha(0.2f).setDuration(200)
cardsWidget.toFan {
cardsWidget.getFirstBackupCardView().animate().alpha(0.6f).duration = 200
cardsWidget.getSecondBackupCardView().animate().alpha(0.2f).duration = 200
}
tvHeader.text = getText(R.string.onboarding_title_no_backup_cards)
tvBody.text = getText(R.string.onboarding_subtitle_no_backup_cards)
@ -244,7 +278,7 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet),
tvBody.text = getText(R.string.onboarding_subtitle_one_backup_card)
cardsWidget.toFan(false)
cardsWidget.getFirstBackupCardView().animate().alpha(1f).setDuration(400)
cardsWidget.getFirstBackupCardView().animate().alpha(1f).duration = 400
cardsWidget.getSecondBackupCardView().alpha = 0.2f
}
2 -> {
@ -253,13 +287,9 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet),
cardsWidget.toFan(false)
cardsWidget.getFirstBackupCardView().alpha = 1f
cardsWidget.getSecondBackupCardView().animate().alpha(1f).setDuration(400)
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() {
@ -290,24 +320,31 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet),
accessCodeDialog?.showError(state.accessCodeError)
}
private fun showWritePrimaryCard(state: BackupState) = with(binding) {
private fun showWritePrimaryCard(state: BackupState, isSaltPay: Boolean) = with(binding) {
accessCodeDialog?.dismiss()
prepareViewForFinalizeStep()
cardsWidget.getSecondBackupCardView().show(state.backupCardsNumber == 2)
cardsWidget.toLeapfrog() {
cardsWidget.toLeapfrog {
cardsWidget.getFirstBackupCardView().alpha = 0.6f
cardsWidget.getSecondBackupCardView().alpha = 0.2f
}
val cardIdFormatter = CardIdFormatter(CardIdDisplayFormat.LastMasked(4))
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) }
)
layoutButtonsCommon.btnMainAction.text = getText(R.string.onboarding_button_backup_origin)
if (isSaltPay) {
tvHeader.text = getText(R.string.onboarding_saltpay_title_prepare_origin)
tvBody.text = getString(R.string.onboarding_twins_interrupt_warning)
layoutButtonsCommon.btnMainAction.text = getText(R.string.onboarding_saltpay_button_backup_origin)
} else {
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) },
)
layoutButtonsCommon.btnMainAction.text = getText(R.string.onboarding_button_backup_origin)
}
layoutButtonsCommon.btnMainAction.setOnClickListener { store.dispatch(BackupAction.WritePrimaryCard) }
}
@ -328,19 +365,19 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet),
layoutButtonsCommon.btnAlternativeAction.hide()
}
private fun showWriteBackupCard(state: BackupState) = with(binding) {
private fun showWriteBackupCard(state: BackupState, isSaltPay: Boolean) = with(binding) {
prepareViewForFinalizeStep()
cardsWidget.getSecondBackupCardView().show(state.backupCardsNumber == 2)
val cardNumber = (state.backupStep as? BackupStep.WriteBackupCard)?.cardNumber ?: 1
when (cardNumber) {
1 -> cardsWidget.leapfrogWidget.leap() {
1 -> cardsWidget.leapfrogWidget.leap {
cardsWidget.getOriginCardView().alpha = 0.4f
cardsWidget.getFirstBackupCardView().alpha = 0.2f
cardsWidget.getSecondBackupCardView().alpha = 1f
}
2 -> cardsWidget.leapfrogWidget.leap() {
2 -> cardsWidget.leapfrogWidget.leap {
cardsWidget.getOriginCardView().alpha = 0.4f
cardsWidget.getFirstBackupCardView().alpha = 0.2f
cardsWidget.getSecondBackupCardView().alpha = 1f
@ -348,22 +385,28 @@ 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)
tvBody.text = getString(
R.string.onboarding_subtitle_scan_backup_card_format,
cardIdFormatter.getFormattedCardId(state.backupCardIds[cardNumber - 1])
)
layoutButtonsCommon.btnMainAction.text = getString(
R.string.onboarding_button_backup_card_format,
cardNumber
)
if (isSaltPay) {
tvHeader.text = getString(R.string.onboarding_saltpay_title_backup_card)
tvBody.text = getString(R.string.onboarding_twins_interrupt_warning)
layoutButtonsCommon.btnMainAction.text = getString(R.string.onboarding_saltpay_title_backup_card)
} else {
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]),
)
layoutButtonsCommon.btnMainAction.text = getString(
R.string.onboarding_button_backup_card_format,
cardNumber,
)
}
layoutButtonsCommon.btnMainAction.setOnClickListener {
store.dispatch(BackupAction.WriteBackupCard(cardNumber))
}
}
private fun showSuccess() = with(binding) {
toolbar.title = getString(R.string.onboarding_done_header)
tvHeader.text = getText(R.string.onboarding_done_header)
tvHeader.show()
@ -389,12 +432,211 @@ class OnboardingWalletFragment : Fragment(R.layout.fragment_onboarding_wallet),
imvSuccess.show()
imvSuccess.animate()
?.alpha(1f)
?.setDuration(400)
?.alpha(1f)?.duration = 400
}
}
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) {
//TODO: SaltPay: remove hardCode
if (state.onboardingSaltPayState?.saltPayCardArtworkUrl == null) {
imvFrontCard.load(R.drawable.img_salt_pay_visa) {
scale(Scale.FILL)
crossfade(enable = true)
}
} else {
walletFragment.loadImageIntoImageView(state.onboardingSaltPayState.saltPayCardArtworkUrl, imvFrontCard)
}
imvFirstBackupCard.load(R.drawable.card_placeholder_wallet)
// 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 -> {
tvHeader.text = walletFragment.getText(R.string.onboarding_saltpay_title_no_backup_card)
tvBody.text = walletFragment.getText(R.string.onboarding_saltpay_subtitle_no_backup_cards)
walletFragment.cardsWidget.getFirstBackupCardView().alpha = 0.6f
walletFragment.cardsWidget.toFan()
}
1 -> {
tvHeader.text = walletFragment.getText(R.string.onboarding_saltpay_title_one_backup_card)
tvBody.text = walletFragment.getText(R.string.onboarding_saltpay_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.KycIntro -> handleKycIntro()
SaltPayRegistrationStep.KycStart -> handleKycStart(state)
SaltPayRegistrationStep.KycWaiting -> handleKycWaiting()
SaltPayRegistrationStep.Finished -> handleFinished()
}
progressButton?.changeState(state.mainButtonState)
}
private fun handleNoGas() {
// normally this shouldn't happen
store.dispatch(NavigationAction.PopBackTo(AppScreen.Home))
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 handleKycIntro() = 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
}
}
}
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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
// }
//}

View file

@ -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

View file

@ -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 -> {

View file

@ -135,7 +135,19 @@ class WalletMiddleware {
.plus(Currency.Blockchain(wallet.blockchain, wallet.publicKey.derivationPath?.rawPath))
}
action.coinsList != null -> action.coinsList
else -> walletState.walletsData.map { it.currency }
else -> {
if (walletState.isMultiwalletAllowed) {
walletState.walletsData.map { it.currency }
} else {
val derivationPath = walletState.primaryWallet?.currency?.derivationPath
val primaryBlockchain = walletState.primaryBlockchain
val primaryToken = walletState.primaryToken
listOfNotNull(
primaryBlockchain?.let { Currency.Blockchain(it, derivationPath) },
primaryToken?.let { Currency.Token(it, primaryBlockchain!!, derivationPath) },
)
}
}
}
val ratesResult = globalState.tapWalletManager.rates.loadFiatRate(
currencyId = appCurrencyId,

View file

@ -21,6 +21,7 @@ import com.tangem.tap.features.wallet.ui.BalanceStatus
import com.tangem.tap.features.wallet.ui.BalanceWidgetData
import com.tangem.tap.features.wallet.ui.TokenData
import com.tangem.tap.store
import java.math.BigDecimal
class OnWalletLoadedReducer {
@ -124,10 +125,12 @@ class OnWalletLoadedReducer {
val tokenFiatAmount =
tokenFiatRate?.let { tokenAmount.value?.toFiatString(it, fiatCurrencyName) }
TokenData(
tokenAmount.value?.toFormattedCurrencyString(
token.decimals, token.symbol
amount = tokenAmount.value ?: BigDecimal.ZERO,
tokenSymbol = tokenAmount.currencySymbol, fiatAmountFormatted = tokenFiatAmount,
fiatAmount = tokenFiatRate?.let { tokenAmount.value?.toFiatValue(tokenFiatRate) },
amountFormatted = tokenAmount.value?.toFormattedCurrencyString(
token.decimals, token.symbol,
) ?: "",
tokenAmount.currencySymbol, tokenFiatAmount
)
} else {
null

View file

@ -31,6 +31,7 @@ import com.tangem.tap.features.wallet.redux.WalletStore
import com.tangem.tap.features.wallet.ui.BalanceStatus
import com.tangem.tap.features.wallet.ui.BalanceWidgetData
import org.rekotlin.Action
import timber.log.Timber
import java.math.BigDecimal
class WalletReducer {
@ -389,16 +390,11 @@ private fun setNewFiatRate(
state = state
)
} else {
val fiatRate = fiatRates.entries.firstOrNull()
val currency = fiatRate?.key ?: return state
val rate = fiatRate.value ?: return state
setSingleWalletFiatRate(
rate = rate,
rateFormatted = rateFormatter(rate),
currency = currency,
setSingleWalletFiatRates(
fiatRates = fiatRates.mapNotNullValues { it.value },
rateFormatter = rateFormatter,
appCurrency = appCurrency,
state = state
state = state,
)
}
}
@ -429,10 +425,10 @@ private fun setMultiWalletFiatRate(
walletData.copy(
currencyData = currencyData.copy(
fiatAmountFormatted = fiatAmountFormatted,
fiatAmount = fiatAmount
fiatAmount = fiatAmount,
),
fiatRate = rate,
fiatRateString = rateFormatter(rate)
fiatRateString = rateFormatter(rate),
)
}
@ -440,15 +436,51 @@ private fun setMultiWalletFiatRate(
.updateWalletsData(newWalletsData)
}
private fun setSingleWalletFiatRates(
fiatRates: Map<Currency, BigDecimal>,
appCurrency: FiatCurrency,
rateFormatter: (BigDecimal) -> String,
state: WalletState,
): WalletState {
val blockchainFiatRate = fiatRates.entries.firstOrNull { it.key.isBlockchain() }
val tokenFiatRate = fiatRates.entries.firstOrNull { it.key.isToken() }
Timber.e("Token Fiat Rate is ${tokenFiatRate ?: "NULL"}")
val updatedState = updateStateWithFiatRate(blockchainFiatRate?.toPair(), appCurrency, rateFormatter, state)
return updateStateWithFiatRate(tokenFiatRate?.toPair(), appCurrency, rateFormatter, updatedState)
}
private fun updateStateWithFiatRate(
fiatRate: Pair<Currency, BigDecimal>?,
appCurrency: FiatCurrency,
rateFormatter: (BigDecimal) -> String,
state: WalletState,
): WalletState {
return if (fiatRate != null) {
val currency = fiatRate.first
val rate = fiatRate.second
setSingleWalletFiatRate(
rate = rate,
rateFormatted = rateFormatter(rate),
currency = currency,
appCurrency = appCurrency,
state = state,
)
} else {
state
}
}
private fun setSingleWalletFiatRate(
rate: BigDecimal,
rateFormatted: String,
currency: Currency,
appCurrency: FiatCurrency,
state: WalletState
state: WalletState,
): WalletState {
val wallet = state.primaryWalletManager?.wallet ?: return state
val token = wallet.getFirstToken()
Timber.e("Working with currency: ${currency.currencyName}")
if (currency == state.primaryWallet?.currency) {
val fiatAmount = wallet.amounts[AmountType.Coin]?.value
@ -459,23 +491,38 @@ private fun setSingleWalletFiatRate(
fiatRateString = rateFormatted
)
return state.updateWalletData(walletData)
} else if (currency is Currency.Token && currency.token == token) {
Timber.e("Working with token fiat rate")
val tokenFiatAmount = wallet.getTokenAmount(token)
?.value
?.toFiatString(rate, appCurrency.code)
val tokenAmountFormatted = tokenFiatAmount?.toFiatString(rate, appCurrency.code)
val tokenData = state.primaryWallet?.currencyData?.token?.copy(
fiatAmountFormatted = tokenAmountFormatted,
fiatAmount = tokenFiatAmount,
fiatRate = rate,
fiatRateString = rateFormatted
fiatRateString = rateFormatted,
)
// ?: TokenData(
// fiatAmountFormatted = tokenAmountFormatted,
// fiatAmount = tokenFiatAmount,
// fiatRate = rate,
// fiatRateString = rateFormatted,
// amount = "",
// tokenSymbol = currency.currencySymbol
// )
Timber.e("Token Data is ${tokenData ?: "NULL"}")
val walletData = state.primaryWallet?.copy(
currencyData = state.primaryWallet.currencyData.copy(
token = tokenData
)
token = tokenData,
),
)
val wallets = walletData?.let { listOf(walletData) } ?: emptyList()
return state.updateWalletsData(wallets)
Timber.e("Wallet Data is ${walletData ?: "NULL"}")
return state.updateWalletData(walletData)
}
return state
}

View file

@ -34,9 +34,11 @@ data class BalanceWidgetData(
)
data class TokenData(
val amount: String,
val amountFormatted: String,
val amount: BigDecimal? = null,
val tokenSymbol: String,
val fiatAmount: String? = null,
val fiatAmountFormatted: String? = null,
val fiatAmount: BigDecimal? = null,
val fiatRateString: String? = null,
val fiatRate: BigDecimal? = null,
)
@ -151,11 +153,11 @@ class BalanceWidget(
groupBaseCurrency.show()
tvCurrency.text = data.token?.tokenSymbol
tvBaseCurrency.text = data.currency
tvAmount.text = if (showAmount) data.token?.amount else ""
tvAmount.text = if (showAmount) data.token?.amountFormatted else ""
tvBaseAmount.text = if (showAmount) data.amountFormatted else ""
if (showAmount) {
tvFiatAmount.show()
tvFiatAmount.text = data.token?.fiatAmount
tvFiatAmount.text = data.token?.fiatAmountFormatted
}
}

View file

@ -26,7 +26,6 @@ import com.tangem.tap.domain.statePrinter.printScanResponseState
import com.tangem.tap.domain.statePrinter.printWalletState
import com.tangem.tap.domain.termsOfUse.CardTou
import com.tangem.tap.features.details.redux.DetailsAction
import com.tangem.tap.features.wallet.redux.Artwork
import com.tangem.tap.features.wallet.redux.ErrorType
import com.tangem.tap.features.wallet.redux.ProgressState
import com.tangem.tap.features.wallet.redux.WalletAction
@ -42,7 +41,6 @@ import com.tangem.wallet.R
import com.tangem.wallet.databinding.FragmentWalletBinding
import org.rekotlin.StoreSubscriber
class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<WalletState> {
private lateinit var warningsAdapter: WarningMessagesAdapter
@ -54,11 +52,14 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
activity?.onBackPressedDispatcher?.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
store.dispatch(NavigationAction.PopBackTo(AppScreen.Home))
}
})
activity?.onBackPressedDispatcher?.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
store.dispatch(NavigationAction.PopBackTo(AppScreen.Home))
}
},
)
val inflater = TransitionInflater.from(requireContext())
enterTransition = inflater.inflateTransition(R.transition.slide_right)
exitTransition = inflater.inflateTransition(R.transition.fade)
@ -89,7 +90,7 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
}
setupWarningsRecyclerView()
walletView.changeWalletView(this, binding)
// addCustomActionOnCard()
addCustomActionOnCard()
}
private fun addCustomActionOnCard() {
@ -111,7 +112,6 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
}
}
override fun newState(state: WalletState) {
if (activity == null || view == null) return
@ -127,7 +127,7 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
walletView = MultiWalletView()
walletView.changeWalletView(this, binding)
}
!state.isMultiwalletAllowed && walletView !is SingleWalletView -> {
!state.isMultiwalletAllowed && !isSaltPay && walletView !is SingleWalletView -> {
walletView = SingleWalletView()
walletView.changeWalletView(this, binding)
}
@ -143,7 +143,7 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
}
setupNoInternetHandling(state)
setupCardImage(state.cardImage)
setupCardImage(state)
if (!isSaltPay) showWarningsIfPresent(state.mainWarningsList)
@ -168,7 +168,7 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
binding.srlWallet.isRefreshing = false
(activity as? MainActivity)?.showSnackbar(
text = R.string.wallet_notification_no_internet,
buttonTitle = R.string.common_retry
buttonTitle = R.string.common_retry,
) { store.dispatch(WalletAction.LoadData) }
}
} else {
@ -176,13 +176,24 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
}
}
private fun setupCardImage(cardImage: Artwork?) {
binding.ivCard.load(cardImage?.artworkId) {
scale(Scale.FIT)
crossfade(enable = true)
placeholder(R.drawable.card_placeholder_black)
error(R.drawable.card_placeholder_black)
fallback(R.drawable.card_placeholder_black)
private fun setupCardImage(state: WalletState) {
//TODO: SaltPay: remove hardCode
if (store.state.globalState.scanResponse?.isSaltPay() == true) {
binding.ivCard.load(R.drawable.img_salt_pay_visa) {
scale(Scale.FIT)
crossfade(enable = true)
placeholder(R.drawable.card_placeholder_black)
error(R.drawable.card_placeholder_black)
fallback(R.drawable.card_placeholder_black)
}
} else {
binding.ivCard.load(state.cardImage?.artworkId) {
scale(Scale.FIT)
crossfade(enable = true)
placeholder(R.drawable.card_placeholder_black)
error(R.drawable.card_placeholder_black)
fallback(R.drawable.card_placeholder_black)
}
}
}
@ -191,11 +202,13 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
R.id.details_menu -> {
store.dispatch(GlobalAction.UpdateFeedbackInfo(store.state.walletState.walletManagers))
store.state.globalState.scanResponse?.let { scanNoteResponse ->
store.dispatch(DetailsAction.PrepareScreen(
scanNoteResponse,
store.state.walletState.walletManagers.map { it.wallet },
CardTou(),
))
store.dispatch(
DetailsAction.PrepareScreen(
scanNoteResponse,
store.state.walletState.walletManagers.map { it.wallet },
CardTou(),
),
)
store.dispatch(NavigationAction.NavigateTo(AppScreen.Details))
true
}
@ -208,5 +221,4 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
if (store.state.walletState.shouldShowDetails) inflater.inflate(R.menu.wallet, menu)
}
}

View file

@ -1,12 +1,15 @@
package com.tangem.tap.features.wallet.ui.wallet
import com.tangem.domain.common.extensions.debounce
import com.tangem.tap.common.entities.FiatCurrency
import com.tangem.tap.common.extensions.animateVisibility
import com.tangem.tap.common.extensions.formatAmountAsSpannedString
import com.tangem.tap.features.wallet.redux.ProgressState
import com.tangem.tap.features.wallet.redux.WalletAction
import com.tangem.tap.mainScope
import com.tangem.tap.store
import com.tangem.wallet.databinding.LayoutSingleWalletBalanceBinding
import org.rekotlin.Action
import java.math.BigDecimal
data class SaltPayBalanceWidgetData(
@ -26,7 +29,7 @@ class SaltPayBalanceWidget(
veilBalance.veil()
veilBalanceCrypto.veil()
} else {
veilBalance.unVeil()
// veilBalance.unVeil()
veilBalanceCrypto.unVeil()
}
tvProcessing.animateVisibility(
@ -35,9 +38,18 @@ class SaltPayBalanceWidget(
veilBalanceCrypto.animateVisibility(
show = data.state != ProgressState.Error,
)
tvBalance.text = data.fiatAmount?.formatAmountAsSpannedString(
currencySymbol = data.fiatCurrency?.symbol ?: "",
)
if (data.fiatAmount == null) {
//TODO: SaltPay: A tricky solution to the problem with displaying rates
// If the rates are loaded after the walletManager.update() is completely updated,
// then this problem can be avoided
actionDebouncer(WalletAction.LoadFiatRate())
} else {
veilBalance.unVeil()
tvBalance.text = data.fiatAmount?.formatAmountAsSpannedString(
currencySymbol = data.fiatCurrency?.symbol ?: "",
)
}
tvBalanceCrypto.text = data.currency
tvCurrencyName.text = data.fiatCurrency?.code
@ -48,4 +60,5 @@ class SaltPayBalanceWidget(
}
}
private val actionDebouncer = debounce<Action>(500, mainScope) { store.dispatch(it) }

View file

@ -2,11 +2,12 @@ package com.tangem.tap.features.wallet.ui.wallet
import com.tangem.tap.common.extensions.hide
import com.tangem.tap.common.extensions.show
import com.tangem.tap.features.wallet.redux.WalletData
import com.tangem.tap.features.wallet.redux.WalletState
import com.tangem.tap.features.wallet.ui.TokenData
import com.tangem.tap.features.wallet.ui.WalletFragment
import com.tangem.tap.store
import com.tangem.wallet.databinding.FragmentWalletBinding
import timber.log.Timber
class SaltPaySingleWalletView : WalletView() {
override fun changeWalletView(fragment: WalletFragment, binding: FragmentWalletBinding) {
@ -18,6 +19,7 @@ class SaltPaySingleWalletView : WalletView() {
private fun showSingleWalletView(binding: FragmentWalletBinding) = with(binding) {
rvMultiwallet.hide()
btnAddToken.hide()
rowButtons.hide()
rvPendingTransaction.hide()
tvTwinCardNumber.hide()
pbLoadingUserTokens.hide()
@ -31,20 +33,21 @@ class SaltPaySingleWalletView : WalletView() {
override fun onNewState(state: WalletState) {
val binding = binding ?: return
state.primaryWallet ?: return
val tokenData = state.primaryWallet?.currencyData?.token ?: return
setupBalance(state, state.primaryWallet, binding)
setupBalance(state, tokenData, binding)
}
private fun setupBalance(state: WalletState, primaryWallet: WalletData, binding: FragmentWalletBinding) {
private fun setupBalance(state: WalletState, tokenData: TokenData, binding: FragmentWalletBinding) {
binding.lSingleWalletBalance.root.show()
Timber.e("Current address is ${state.primaryWalletManager?.wallet?.address}")
SaltPayBalanceWidget(
binding = binding.lSingleWalletBalance,
data = SaltPayBalanceWidgetData(
state = state.state,
currencySymbol = primaryWallet.currencyData.currencySymbol,
currency = primaryWallet.currencyData.amountFormatted,
fiatAmount = primaryWallet.currencyData.fiatAmount,
currencySymbol = tokenData.tokenSymbol,
currency = tokenData.amountFormatted,
fiatAmount = tokenData.fiatAmount, // TODO: show fiatAmount
fiatCurrency = store.state.globalState.appCurrency,
),
).setup()

View file

@ -15,6 +15,7 @@ class PreferencesStorage(applicationContext: Application) {
val appRatingLaunchObserver: AppRatingLaunchObserver
val usedCardsPrefStorage: UsedCardsPrefStorage
val fiatCurrenciesPrefStorage: FiatCurrenciesPrefStorage
val saltPayRegistrationStorage: SaltPayRegistrationStorage
init {
incrementLaunchCounter()
@ -23,6 +24,7 @@ class PreferencesStorage(applicationContext: Application) {
usedCardsPrefStorage.migrate()
fiatCurrenciesPrefStorage = FiatCurrenciesPrefStorage(preferences, MoshiJsonConverter.INSTANCE)
fiatCurrenciesPrefStorage.migrate()
saltPayRegistrationStorage = SaltPayRegistrationPrefStorage(applicationContext, MoshiJsonConverter.INSTANCE)
}
var chatFirstLaunchTime: Long?

View file

@ -0,0 +1,82 @@
package com.tangem.tap.persistence
import android.content.Context
import androidx.core.content.edit
import at.favre.lib.armadillo.ArmadilloSharedPreferences
import com.tangem.common.json.MoshiJsonConverter
import com.tangem.common.services.secure.SecureStorage
import com.tangem.tangem_sdk_new.storage.createEncryptedSharedPreferences
/**
[REDACTED_AUTHOR]
*/
interface SaltPayRegistrationStorage {
var data: SaltPayRegistratorData
fun reset()
companion object {
fun stub(): SaltPayRegistrationStorage = object : SaltPayRegistrationStorage {
override var data: SaltPayRegistratorData = SaltPayRegistratorData()
override fun reset() {}
}
}
}
data class SaltPayRegistratorData(
val transactionsSent: Boolean = false,
)
class SaltPayRegistrationPrefStorage(
context: Context,
private val converter: MoshiJsonConverter,
) : SaltPayRegistrationStorage {
private val storage: ArmadilloSharedPreferences = SecureStorage.createEncryptedSharedPreferences(
context = context,
storageName = "saltpay",
)
private var isFetching: Boolean = false
init {
fetch()
}
override var data: SaltPayRegistratorData = SaltPayRegistratorData()
set(value) {
field = value
if (!isFetching) save()
}
override fun reset() {
storage.edit(true) {
this.remove(REGISTRATION_DATA_KEY)
data = SaltPayRegistratorData()
}
}
private fun save() {
storage.edit(true) {
putString(REGISTRATION_DATA_KEY, converter.toJson(data))
}
}
private fun fetch(): SaltPayRegistratorData {
isFetching = true
val jsonData = storage.getString(REGISTRATION_DATA_KEY, null)
data = if (jsonData == null) {
SaltPayRegistratorData()
} else {
converter.fromJson<SaltPayRegistratorData>(jsonData) ?: SaltPayRegistratorData()
}
isFetching = false
return data
}
companion object {
private const val REGISTRATION_DATA_KEY = "saltpay_registration_data"
}
}

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="66dp"
android:height="65dp"
android:viewportWidth="66"
android:viewportHeight="65">
<path
android:pathData="M33.43,36.488L24.961,44.957L22.97,42.967L20.924,40.935L29.392,32.465C30.285,31.573 30.634,30.272 30.307,29.053C29.98,27.834 29.028,26.881 27.809,26.554C26.59,26.228 25.288,26.576 24.396,27.469L15.927,35.938L13.937,33.948C13.681,33.649 13.312,33.47 12.919,33.455C12.526,33.44 12.145,33.589 11.867,33.868C11.588,34.146 11.439,34.527 11.454,34.92C11.469,35.313 11.648,35.682 11.946,35.938L5.961,41.979C4.639,43.303 3.895,45.097 3.895,46.969C3.895,48.84 4.639,50.635 5.961,51.958L6.964,52.961L3.957,55.953C3.41,56.503 3.41,57.392 3.957,57.943L4.96,58.945L1.374,62.516C1.076,62.772 0.897,63.141 0.882,63.534C0.867,63.927 1.016,64.309 1.294,64.587C1.572,64.865 1.954,65.014 2.347,64.999C2.74,64.984 3.109,64.805 3.364,64.507L6.95,60.921L7.952,61.924C8.503,62.471 9.392,62.471 9.942,61.924L12.935,58.931L13.937,59.934H13.936C15.26,61.257 17.055,62 18.926,62C20.798,62 22.592,61.256 23.916,59.934L29.9,53.949V53.949C30.157,54.247 30.525,54.426 30.919,54.441C31.311,54.456 31.693,54.307 31.971,54.028C32.249,53.751 32.398,53.369 32.383,52.976C32.368,52.583 32.19,52.214 31.891,51.958L29.886,49.954L38.355,41.485V41.485C39.096,40.578 39.339,39.364 39.003,38.242C38.668,37.12 37.798,36.238 36.681,35.887C35.565,35.535 34.347,35.761 33.43,36.488L33.43,36.488ZM26.372,29.501C26.649,29.224 27.098,29.224 27.374,29.501C27.651,29.778 27.651,30.227 27.374,30.504L18.905,38.973L17.903,37.985L26.372,29.501ZM10.846,56.956L8.954,58.946L6.95,56.941L8.94,54.951L10.846,56.956ZM21.827,57.958V57.957C21.033,58.75 19.957,59.196 18.835,59.196C17.712,59.196 16.636,58.75 15.842,57.957L7.952,49.954C7.159,49.16 6.714,48.084 6.714,46.962C6.714,45.84 7.159,44.763 7.952,43.97L13.937,37.985L27.911,51.959L21.827,57.958ZM36.394,39.523L27.925,47.992L26.923,46.99L35.392,38.521V38.521C35.669,38.244 36.117,38.244 36.394,38.521C36.671,38.798 36.671,39.247 36.394,39.523V39.523Z"
android:fillColor="#919191"/>
<path
android:pathData="M65.485,0.41C64.934,-0.137 64.045,-0.137 63.494,0.41L56.014,7.891L55.011,6.889V6.889C54.461,6.342 53.572,6.342 53.021,6.889L49.789,10.122L48.786,9.119L48.787,9.12C47.463,7.796 45.668,7.053 43.797,7.053C41.925,7.053 40.131,7.796 38.807,9.12L31.312,16.615C30.752,16.135 29.918,16.167 29.396,16.688C28.875,17.21 28.843,18.045 29.322,18.605L30.325,19.607L46.274,35.586L47.277,36.588L47.276,36.588C47.836,37.067 48.671,37.035 49.193,36.513C49.714,35.992 49.746,35.158 49.266,34.597L56.761,27.103H56.762C58.084,25.779 58.828,23.984 58.828,22.113C58.828,20.241 58.084,18.446 56.762,17.123L55.759,16.121L58.991,12.889H58.992C59.539,12.338 59.539,11.449 58.992,10.898L58.004,9.882L65.47,2.415C65.74,2.152 65.892,1.792 65.895,1.416C65.897,1.04 65.75,0.678 65.484,0.411L65.485,0.41ZM54.771,25.098L47.276,32.593L33.302,18.619L40.797,11.124C41.604,10.354 42.675,9.924 43.79,9.924C44.904,9.924 45.976,10.354 46.782,11.124L54.772,19.113L54.771,19.113C55.564,19.906 56.01,20.983 56.01,22.105C56.01,23.227 55.564,24.304 54.771,25.097L54.771,25.098ZM53.769,14.116L51.779,12.126L54.023,9.881L56.014,11.872L53.769,14.116Z"
android:fillColor="#919191"/>
</vector>

View file

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="142dp"
android:height="142dp"
android:viewportWidth="142"
android:viewportHeight="142">
<path
android:pathData="M71,0L71,0A71,71 0,0 1,142 71L142,71A71,71 0,0 1,71 142L71,142A71,71 0,0 1,0 71L0,71A71,71 0,0 1,71 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M71,142C110.21,142 142,110.21 142,71C142,31.79 110.21,0 71,0C31.79,0 0,31.79 0,71C0,110.21 31.79,142 71,142Z"
android:strokeAlpha="0.2"
android:fillColor="#FFB71B"
android:fillType="evenOdd"
android:fillAlpha="0.2"/>
<path
android:pathData="M70.66,117.31C96.43,117.31 117.31,96.43 117.31,70.66C117.31,44.89 96.43,24 70.66,24C44.89,24 24,44.89 24,70.66C24,96.43 44.89,117.31 70.66,117.31Z"
android:fillColor="#FFB71B"
android:fillType="evenOdd"/>
<path
android:pathData="M70.53,88.08C80.16,88.08 88.06,80.16 88.06,70.53C88.06,60.92 80.14,53 70.53,53C60.9,53 53,60.92 53,70.53C53,80.16 60.92,88.08 70.53,88.08ZM70.53,83.71C63.24,83.71 57.38,77.84 57.38,70.53C57.38,63.24 63.23,57.37 70.53,57.37C77.82,57.37 83.7,63.24 83.7,70.53C83.7,77.84 77.84,83.71 70.53,83.71ZM62.65,73.07H70.53C71.43,73.07 72.13,72.36 72.13,71.46V61.13C72.13,60.24 71.43,59.54 70.53,59.54C69.63,59.54 68.92,60.24 68.92,61.13V69.87H62.65C61.75,69.87 61.05,70.56 61.05,71.46C61.05,72.36 61.75,73.07 62.65,73.07Z"
android:fillColor="#ffffff"/>
</vector>

View file

@ -0,0 +1,26 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="67dp"
android:height="66dp"
android:viewportWidth="67"
android:viewportHeight="66">
<path
android:pathData="M11.5,9.75L55.5,9.75A6.75,6.75 0,0 1,62.25 16.5L62.25,49.5A6.75,6.75 0,0 1,55.5 56.25L11.5,56.25A6.75,6.75 0,0 1,4.75 49.5L4.75,16.5A6.75,6.75 0,0 1,11.5 9.75z"
android:strokeWidth="3"
android:fillColor="#00000000"
android:strokeColor="#919191"/>
<path
android:pathData="M28.068,26.775C28.068,30.44 25.458,33.183 22.188,33.183C18.945,33.183 16.335,30.44 16.335,26.775C16.335,23.348 18.945,20.5 22.188,20.5C25.458,20.5 28.068,23.348 28.068,26.775Z"
android:fillColor="#919191"/>
<path
android:pathData="M54.515,23.875H39.67C38.905,23.875 38.352,23.295 38.352,22.557C38.352,21.845 38.905,21.291 39.67,21.291H54.515C55.253,21.291 55.833,21.845 55.833,22.557C55.833,23.295 55.253,23.875 54.515,23.875Z"
android:fillColor="#919191"/>
<path
android:pathData="M54.515,34.686H39.67C38.905,34.686 38.352,34.105 38.352,33.367C38.352,32.655 38.905,32.075 39.67,32.075H54.515C55.253,32.075 55.833,32.655 55.833,33.367C55.833,34.105 55.253,34.686 54.515,34.686Z"
android:fillColor="#919191"/>
<path
android:pathData="M32.894,45.496H11.51C10.745,45.496 10.402,44.995 10.402,44.31C10.402,41.752 14.173,35.055 22.188,35.055C30.204,35.055 34.001,41.752 34.001,44.31C34.001,44.995 33.658,45.496 32.894,45.496Z"
android:fillColor="#919191"/>
<path
android:pathData="M54.515,45.496H39.67C38.905,45.496 38.352,44.916 38.352,44.204C38.352,43.466 38.905,42.886 39.67,42.886H54.515C55.253,42.886 55.833,43.466 55.833,44.204C55.833,44.916 55.253,45.496 54.515,45.496Z"
android:fillColor="#919191"/>
</vector>

View file

@ -1,21 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="42dp"
android:height="42dp"
android:autoMirrored="true"
android:viewportWidth="42"
android:viewportHeight="42">
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24h-24z"/>
<path
android:fillColor="#F4F5F5"
android:pathData="M21,21m-21,0a21,21 0,1 1,42 0a21,21 0,1 1,-42 0" />
<group>
<clip-path android:pathData="M21,42C32.598,42 42,32.598 42,21C42,9.402 32.598,0 21,0C9.402,0 0,9.402 0,21C0,32.598 9.402,42 21,42Z" />
<path
android:fillColor="#0F1820"
android:pathData="M0,8L27,8A4,4 0,0 1,31 12L31,30A4,4 0,0 1,27 34L0,34A4,4 0,0 1,-4 30L-4,12A4,4 0,0 1,0 8z" />
<path
android:fillAlpha="0.3"
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M9.047,27H24.954C25.231,27 25.497,27.105 25.694,27.293C25.89,27.48 26,27.735 26,28C26,28.265 25.89,28.52 25.694,28.707C25.497,28.895 25.231,29 24.954,29H9.047C8.769,29 8.503,28.895 8.307,28.707C8.11,28.52 8,28.265 8,28C8,27.735 8.11,27.48 8.307,27.293C8.503,27.105 8.769,27 9.047,27Z" />
</group>
android:pathData="M12,7m-1.25,0a1.25,1.25 0,1 1,2.5 0a1.25,1.25 0,1 1,-2.5 0"
android:fillColor="#000000"/>
<path
android:pathData="M14.5,9.5C15.881,8.119 15.881,5.881 14.5,4.5M9.5,9.5C8.119,8.119 8.119,5.881 9.5,4.5"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
<path
android:pathData="M17,12C19.761,9.239 19.761,4.761 17,2M7,12C4.239,9.239 4.239,4.761 7,2"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
<path
android:pathData="M12.662,11.945L22.754,22.036A1.25,1.25 0,0 1,22.754 23.804L17.804,28.753A1.25,1.25 0,0 1,16.036 28.753L5.945,18.662A1.25,1.25 0,0 1,5.945 16.894L10.894,11.945A1.25,1.25 0,0 1,12.662 11.945z"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
<path
android:pathData="M7.354,15.475L9.475,13.354A0.5,0.5 119.055,0 1,10.182 13.354L10.889,14.061A0.5,0.5 81.905,0 1,10.889 14.768L8.768,16.889A0.5,0.5 51.198,0 1,8.061 16.889L7.354,16.182A0.5,0.5 98.144,0 1,7.354 15.475z"
android:fillColor="#000000"/>
</group>
</vector>

View file

@ -1,7 +1,15 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#000000" android:pathData="M4,3.9C4,3.403 4.421,3 4.941,3H19.059C19.579,3 20,3.403 20,3.9V8.4H4V3.9Z"/>
<path android:fillColor="#000000" android:pathData="M4,12H9.647V21H4.941C4.421,21 4,20.597 4,20.1V12Z"/>
<path android:fillColor="#000000" android:pathData="M20,12H14.353V21H19.059C19.579,21 20,20.597 20,20.1V12Z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="21dp"
android:height="20dp"
android:viewportWidth="21"
android:viewportHeight="20">
<path
android:pathData="M3.833,3.25C3.833,2.836 4.184,2.5 4.618,2.5H16.382C16.815,2.5 17.167,2.836 17.167,3.25V7H3.833V3.25Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M3.833,10H8.539V17.5H4.618C4.184,17.5 3.833,17.164 3.833,16.75V10Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M17.167,10H12.461V17.5H16.382C16.815,17.5 17.167,17.164 17.167,16.75V10Z"
android:fillColor="#ffffff"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View file

@ -27,30 +27,21 @@
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_details_confirm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:background="@color/white"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.core.widget.NestedScrollView
android:id="@+id/sv_wallet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="true"
android:overScrollMode="never"
app:layout_constraintBottom_toBottomOf="parent">
<TextView
android:id="@+id/tv_warning_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="22dp"
android:paddingStart="16dp"
android:paddingEnd="24dp"
android:paddingBottom="120dp"
android:text="@string/disclaimer_text"
android:textAlignment="textStart"
android:textSize="16sp" />
</androidx.core.widget.NestedScrollView>
<WebView
android:id="@+id/web_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline"
@ -66,8 +57,8 @@
android:layout_height="100dp"
android:background="@drawable/bg_half_transparent_overlay"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_accept"
@ -75,12 +66,12 @@
android:layout_width="0dp"
android:layout_marginStart="7dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="24dp"
android:text="@string/common_accept"
app:icon="@drawable/ic_send"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/guideline"
android:layout_marginBottom="24dp" />
app:layout_constraintStart_toEndOf="@+id/guideline" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -28,16 +28,17 @@
</com.google.android.material.appbar.AppBarLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/onboarding_main_container"
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/onboarding_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
android:orientation="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<include
layout="@layout/view_onboarding_progress"
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="16dp"
@ -46,175 +47,188 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/imv_card_background"
android:layout_width="236dp"
android:layout_height="236dp"
android:adjustViewBounds="true"
android:background="@drawable/shape_circle"
android:backgroundTint="@color/lightGray0"
android:elevation="0dp"
android:scaleType="fitCenter"
app:layout_constraintBottom_toBottomOf="@id/fl_cards_container"
app:layout_constraintEnd_toEndOf="@id/fl_cards_container"
app:layout_constraintStart_toStartOf="@id/fl_cards_container"
app:layout_constraintTop_toTopOf="@id/fl_cards_container" />
<FrameLayout
android:id="@+id/fl_cards_container"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="0dp"
android:clipChildren="false"
app:layout_constraintBottom_toTopOf="@id/guideline2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/pb_state">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/imv_second_backup_card"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:scaleType="fitCenter"
android:src="@drawable/card_placeholder_black"
android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/imv_first_backup_card"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:scaleType="fitCenter"
android:src="@drawable/card_placeholder_black"
android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/imv_front_card"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:scaleType="fitCenter"
android:src="@drawable/card_placeholder_black"
android:transitionName="imv_front_card" />
</FrameLayout>
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/imv_success"
android:layout_width="142dp"
android:layout_height="142dp"
android:src="@drawable/ic_activation_success"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/guideline2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.495"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/pb_state" />
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/ll_header_body_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="72dp"
android:layout_marginEnd="32dp"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/guideline2">
</androidx.appcompat.widget.LinearLayoutCompat>
<TextView
android:id="@+id/tv_header"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fontFamily="sans-serif-medium"
android:textAlignment="center"
android:textColor="#060606"
android:textSize="24sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.504"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/guideline2"
tools:text="Добавлена одна резервная карта" />
<TextView
android:id="@+id/tv_body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/home_bottom_tv_body_margin_start"
android:layout_marginTop="@dimen/home_bottom_tv_body_margin_top"
android:layout_marginEnd="@dimen/home_bottom_tv_body_margin_end"
android:textAlignment="center"
android:textColor="#686868"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tv_header"
tools:text="Body" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager_backup_info"
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/onboarding_wallet_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
app:flow_verticalBias="0"
app:layout_constraintTop_toTopOf="@+id/guideline2" />
android:layout_height="match_parent"
android:clipChildren="false">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_layout_backup_info"
android:layout_width="match_parent"
android:layout_height="10dp"
android:layout_marginBottom="16dp"
android:background="@color/backgroundLightGray"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/layout_buttons_common"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/view_pager_backup_info"
app:tabBackground="@drawable/selector_tabs_dots"
app:tabGravity="center"
app:tabIndicator="@null"
app:tabPaddingEnd="8dp"
app:tabPaddingStart="8dp" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/imv_card_background"
android:layout_width="236dp"
android:layout_height="236dp"
android:adjustViewBounds="true"
android:background="@drawable/shape_circle"
android:backgroundTint="@color/lightGray0"
android:elevation="0dp"
android:scaleType="fitCenter"
app:layout_constraintBottom_toBottomOf="@id/fl_cards_container"
app:layout_constraintEnd_toEndOf="@id/fl_cards_container"
app:layout_constraintStart_toStartOf="@id/fl_cards_container"
app:layout_constraintTop_toTopOf="@id/fl_cards_container" />
<FrameLayout
android:id="@+id/fl_cards_container"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="0dp"
android:clipChildren="false"
app:layout_constraintBottom_toTopOf="@id/guideline2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/imv_second_backup_card"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:scaleType="fitCenter"
android:src="@drawable/card_placeholder_black"
android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/imv_first_backup_card"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:scaleType="fitCenter"
android:src="@drawable/card_placeholder_black"
android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/imv_front_card"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:scaleType="fitCenter"
android:src="@drawable/card_placeholder_black"
android:transitionName="imv_front_card" />
</FrameLayout>
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/imv_success"
android:layout_width="142dp"
android:layout_height="142dp"
android:src="@drawable/ic_activation_success"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/guideline2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.495"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/ll_header_body_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="72dp"
android:layout_marginEnd="32dp"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/guideline2">
</androidx.appcompat.widget.LinearLayoutCompat>
<TextView
android:id="@+id/tv_header"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fontFamily="sans-serif-medium"
android:textAlignment="center"
android:textColor="#060606"
android:textSize="24sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.504"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/guideline2"
tools:text="Добавлена одна резервная карта" />
<TextView
android:id="@+id/tv_body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/home_bottom_tv_body_margin_start"
android:layout_marginTop="@dimen/home_bottom_tv_body_margin_top"
android:layout_marginEnd="@dimen/home_bottom_tv_body_margin_end"
android:textAlignment="center"
android:textColor="#686868"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tv_header"
tools:text="Body" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager_backup_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
app:flow_verticalBias="0"
app:layout_constraintTop_toTopOf="@+id/guideline2" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_layout_backup_info"
android:layout_width="match_parent"
android:layout_height="10dp"
android:layout_marginBottom="16dp"
android:background="@color/backgroundLightGray"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/layout_buttons_common"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/view_pager_backup_info"
app:tabBackground="@drawable/selector_tabs_dots"
app:tabGravity="center"
app:tabIndicator="@null"
app:tabPaddingEnd="8dp"
app:tabPaddingStart="8dp" />
<include
android:id="@+id/layout_buttons_common"
layout="@layout/layout_onboarding_buttons_common"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent" />
<include
android:id="@+id/layout_buttons_add_cards"
layout="@layout/layout_onboarding_buttons_add_cards"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintGuide_percent="@dimen/onboarding_wallet_top_guide_line_weight"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<include
android:id="@+id/layout_buttons_common"
layout="@layout/layout_onboarding_buttons_common"
android:id="@+id/onboarding_saltpay_container"
layout="@layout/layout_onboarding_saltpay"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent" />
android:layout_height="match_parent"
android:visibility="gone" />
<include
android:id="@+id/layout_buttons_add_cards"
layout="@layout/layout_onboarding_buttons_add_cards"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintGuide_percent="@dimen/onboarding_wallet_top_guide_line_weight"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.appcompat.widget.LinearLayoutCompat>
<include
android:id="@+id/v_confetti"

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