Updated on 2026-08-14

This commit is contained in:
Tangem 2022-10-25 16:38:52 +05:00
commit 707f04c6a5
232 changed files with 7456 additions and 1908 deletions

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,
@ -144,12 +146,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,10 @@ 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.saltPay.dialog.TryToInterruptRegistrationDialog
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
@ -51,7 +55,6 @@ class DialogManager : StoreSubscriber<GlobalState> {
store.unsubscribe(this)
}
override fun newState(state: GlobalState) {
if (state.dialog == null) {
dialog?.dismiss()
@ -64,6 +67,8 @@ class DialogManager : StoreSubscriber<GlobalState> {
dialog = when (state.dialog) {
is AppDialog.SimpleOkDialog -> SimpleOkDialog.create(state.dialog, context)
is AppDialog.SimpleOkDialogRes -> SimpleOkDialog.create(state.dialog, context)
is AppDialog.SimpleOkErrorDialog -> SimpleOkDialog.create(state.dialog, context)
is AppDialog.SimpleOkWarningDialog -> SimpleOkDialog.create(state.dialog, context)
is AppDialog.ScanFailsDialog -> ScanFailsDialog.create(context)
is AppDialog.AddressInfoDialog -> AddressInfoBottomSheetDialog(state.dialog, context)
is AppDialog.TestActionsDialog -> TestActionsBottomSheetDialog(state.dialog, context)
@ -73,16 +78,16 @@ class DialogManager : StoreSubscriber<GlobalState> {
SimpleAlertDialog.create(
titleRes = R.string.wallet_connect,
messageRes = R.string.wallet_connect_scanner_error_no_ethereum_wallet,
context = context
context = context,
)
is WalletConnectDialog.AddNetwork ->
SimpleAlertDialog.create(
titleRes = R.string.wallet_connect,
message = context.getString(
R.string.wallet_connect_network_not_found_format,
state.dialog.network
state.dialog.network,
),
context = context
context = context,
)
is WalletConnectDialog.OpeningSessionRejected -> {
SimpleAlertDialog.create(
@ -120,24 +125,23 @@ class DialogManager : StoreSubscriber<GlobalState> {
SimpleAlertDialog.create(
titleRes = R.string.wallet_connect,
messageRes = R.string.wallet_connect_scanner_error_unsupported_network,
context = context
context = context,
)
is BackupDialog.AddMoreBackupCards -> AddMoreBackupCardsDialog.create(context)
is BackupDialog.BackupInProgress -> BackupInProgressDialog.create(context)
is BackupDialog.UnfinishedBackupFound -> UnfinishedBackupFoundDialog.create(context)
is BackupDialog.ConfirmDiscardingBackup -> ConfirmDiscardingBackupDialog.create(context)
is WalletDialog.CurrencySelectionDialog ->
CurrencySelectionDialog.create(state.dialog, context)
is WalletDialog.ChooseTradeActionDialog ->
ChooseTradeActionBottomSheetDialog(context)
is WalletDialog.SelectAmountToSendDialog ->
AmountToSendBottomSheetDialog(context, state.dialog)
is WalletDialog.SignedHashesMultiWalletDialog ->
SignedHashesWarningDialog.create(context)
is SaltPayDialog.Activation.NoGas -> NoFundsForActivationDialog.create(context)
is SaltPayDialog.Activation.TryToInterrupt -> TryToInterruptRegistrationDialog.create(context, state.dialog)
is SaltPayDialog.Activation.OnError -> RegistrationErrorDialog.create(context, state.dialog)
is WalletDialog.CurrencySelectionDialog -> CurrencySelectionDialog.create(state.dialog, context)
is WalletDialog.ChooseTradeActionDialog -> ChooseTradeActionBottomSheetDialog(context)
is WalletDialog.SelectAmountToSendDialog -> AmountToSendBottomSheetDialog(context, state.dialog)
is WalletDialog.SignedHashesMultiWalletDialog -> SignedHashesWarningDialog.create(context)
is WalletDialog.TokensAreLinkedDialog -> SimpleAlertDialog.create(
title = context.getString(state.dialog.titleRes, state.dialog.currencySymbol),
message = context.getString(
state.dialog.messageRes, state.dialog.currencySymbol, state.dialog.currencyTitle
state.dialog.messageRes, state.dialog.currencySymbol, state.dialog.currencyTitle,
),
context = context,
)
@ -146,7 +150,7 @@ class DialogManager : StoreSubscriber<GlobalState> {
messageRes = state.dialog.messageRes,
context = context,
primaryButtonRes = state.dialog.primaryButtonRes,
primaryButtonAction = state.dialog.onOk
primaryButtonAction = state.dialog.onOk,
)
is WalletDialog.RussianCardholdersWarningDialog ->
RussianCardholdersWarningBottomSheetDialog(context)

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

@ -27,8 +27,8 @@ object TestActions {
* @param isTestView - true must be used if you want to show or hide your view depends on BuildConfig
*/
fun initFor(view: View, actions: List<TestAction>, isTestView: Boolean = false) {
if (isTestView) view.show(BuildConfig.TEST_ACTION_ENABLED)
if (!BuildConfig.TEST_ACTION_ENABLED) return
if (isTestView) view.show(BuildConfig.TEST_ACTION_ENABLED)
view.setOnClickListener {
store.dispatchDialogShow(AppDialog.TestActionsDialog(actions))

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

@ -28,7 +28,10 @@ fun Blockchain.getRoundIconRes(): Int {
Blockchain.Dogecoin -> R.drawable.ic_dogecoin_round
Blockchain.Tron, Blockchain.TronTestnet -> R.drawable.ic_tron_round
Blockchain.Gnosis -> R.drawable.ic_gnosis_round
Blockchain.EthereumPow, Blockchain.EthereumPowTestnet -> R.drawable.ic_ethereumpow_round
Blockchain.EthereumFair -> R.drawable.ic_ethereumfair_round
Blockchain.Polkadot, Blockchain.PolkadotTestnet -> R.drawable.ic_polkadot_round
Blockchain.Kusama -> R.drawable.ic_kusama_round
Blockchain.Optimism, Blockchain.OptimismTestnet -> R.drawable.ic_optimism
Blockchain.Dash -> R.drawable.ic_dash
else -> R.drawable.ic_tangem_logo
@ -58,7 +61,10 @@ fun Blockchain.getGreyedOutIconRes(): Int {
Blockchain.Dogecoin -> R.drawable.ic_dogecoin_no_color
Blockchain.Tron, Blockchain.TronTestnet -> R.drawable.ic_tron_no_color
Blockchain.Gnosis -> R.drawable.ic_gnosis_no_color
Blockchain.EthereumPow, Blockchain.EthereumPowTestnet -> R.drawable.ic_ethereumpow_no_color
Blockchain.EthereumFair -> R.drawable.ic_ethereumfair_no_color
Blockchain.Polkadot, Blockchain.PolkadotTestnet -> R.drawable.ic_polkadot_no_color
Blockchain.Kusama -> R.drawable.ic_kusama_no_color
Blockchain.Optimism, Blockchain.OptimismTestnet -> R.drawable.ic_optimism_no_color
Blockchain.Dash -> R.drawable.ic_dash_no_color
else -> R.drawable.ic_tangem_logo

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

@ -0,0 +1,11 @@
package com.tangem.tap.common.extensions
import android.widget.ImageView
import androidx.annotation.DrawableRes
/**
[REDACTED_AUTHOR]
*/
fun ImageView.setDrawable(@DrawableRes resId: Int) {
setImageDrawable(context.getDrawableCompat(resId))
}

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

@ -7,16 +7,19 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.Wallet
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.common.address.Address
import com.tangem.common.card.CardWallet
import com.tangem.domain.common.ScanResponse
import com.tangem.tap.common.extensions.stripZeroPlainString
class AdditionalFeedbackInfo {
class EmailWalletInfo(
var blockchain: Blockchain = Blockchain.Unknown,
var address: String = "",
var explorerLink: String = "",
var host: String = "",
var derivationPath: String = "",
var outputsCount: String? = null,
var host: String = "",
var addresses: String = "",
var explorerLink: String = "",
)
var appVersion: String = ""
@ -48,23 +51,14 @@ class AdditionalFeedbackInfo {
cardBlockchain = data.walletData?.blockchain ?: ""
cardFirmwareVersion = data.card.firmwareVersion.stringValue
cardIssuer = data.card.issuer.name
signedHashesCount = data.card.wallets
.joinToString("; ") { "${it.curve.curve} - ${it.totalSignedHashes}" }
signedHashesCount = formatSignedHashes(data.card.wallets)
}
fun setWalletsInfo(walletManagers: List<WalletManager>) {
walletsInfo.clear()
tokens.clear()
walletManagers.forEach { manager ->
walletsInfo.add(
EmailWalletInfo(
blockchain = manager.wallet.blockchain,
address = getAddress(manager.wallet),
explorerLink = getExploreUri(manager.wallet),
host = manager.currentHost,
derivationPath = manager.wallet.publicKey.derivationPath?.rawPath ?: ""
)
)
walletsInfo.add(createEmailWalletInfo(manager))
if (manager.cardTokens.isNotEmpty()) {
tokens[manager.wallet.blockchain] = manager.cardTokens
}
@ -72,45 +66,55 @@ class AdditionalFeedbackInfo {
}
fun updateOnSendError(
wallet: Wallet,
host: String,
walletManager: WalletManager,
amountToSend: Amount,
feeAmount: Amount,
destinationAddress: String,
) {
onSendErrorWalletInfo = EmailWalletInfo(
blockchain = wallet.blockchain,
address = getAddress(wallet),
explorerLink = getExploreUri(wallet),
host = host,
derivationPath = wallet.publicKey.derivationPath?.rawPath ?: ""
)
onSendErrorWalletInfo = createEmailWalletInfo(walletManager)
this.destinationAddress = destinationAddress
amount = amountToSend.value?.stripZeroPlainString() ?: "0"
fee = feeAmount.value?.stripZeroPlainString() ?: "0"
token = if (amountToSend.type is AmountType.Token) amountToSend.currencySymbol else ""
}
private fun getAddress(wallet: Wallet): String {
return if (wallet.addresses.size == 1) {
wallet.address
} else {
val addresses = wallet.addresses.joinToString(", ") {
"${it.type.javaClass.simpleName} - ${it.value}"
}
"Multiple address: $addresses"
private fun createEmailWalletInfo(walletManager: WalletManager): EmailWalletInfo {
return EmailWalletInfo(
blockchain = walletManager.wallet.blockchain,
derivationPath = walletManager.wallet.publicKey.derivationPath?.rawPath ?: "",
outputsCount = walletManager.outputsCount?.toString(),
host = walletManager.currentHost,
addresses = formatAddresses(walletManager.wallet),
explorerLink = formatExploreUrls(walletManager.wallet),
)
}
private fun formatSignedHashes(wallets: List<CardWallet>): String {
return wallets.joinToString("\n") { "Signed hashes: ${it.curve.curve} - ${it.totalSignedHashes}" }
}
private fun formatAddresses(wallet: Wallet): String {
return wallet.formatAddressWith("Multiple address:") {
"${it.name} - ${it.value}"
}
}
private fun getExploreUri(wallet: Wallet): String {
return if (wallet.addresses.size == 1) {
wallet.getExploreUrl(wallet.address)
} else {
val links = wallet.addresses.joinToString(", ") {
"${it.type.javaClass.simpleName} - ${wallet.getExploreUrl(it.value)}"
}
"Multiple explorers links: $links"
private fun formatExploreUrls(wallet: Wallet): String {
return wallet.formatAddressWith("Multiple explorers links:") {
"${it.name} - ${wallet.getExploreUrl(it.value)}"
}
}
private fun Wallet.formatAddressWith(with: String, mapAddress: (Address) -> String): String {
return if (addresses.size == 1) {
getExploreUrl(address)
} else {
addresses.map { mapAddress(it) }.toMutableList()
.apply { add(0, with) }
.joinToString("\n")
}
}
private val Address.name: String
get() = type.javaClass.simpleName
}

View file

@ -3,7 +3,7 @@ package com.tangem.tap.common.feedback
import com.tangem.tap.common.extensions.breakLine
class FeedbackDataBuilder(
private val infoHolder: AdditionalFeedbackInfo
private val infoHolder: AdditionalFeedbackInfo,
) {
val builder = StringBuilder()
@ -21,7 +21,7 @@ class FeedbackDataBuilder(
builder.appendKeyValue("Card ID", infoHolder.cardId)
builder.appendKeyValue("Firmware version", infoHolder.cardFirmwareVersion)
builder.appendKeyValue("Card Blockchain", infoHolder.cardBlockchain)
builder.appendKeyValue("Signed hashes", infoHolder.signedHashesCount)
builder.appendKeyValue("", infoHolder.signedHashesCount)
return this
}
@ -29,20 +29,22 @@ class FeedbackDataBuilder(
infoHolder.walletsInfo.forEach {
builder.appendDelimiter()
builder.appendKeyValue("Blockchain", it.blockchain.fullName)
builder.appendKeyValue("Host", it.host)
builder.appendKeyValue("Wallet address", it.address)
builder.appendKeyValue("Derivation path", it.derivationPath)
builder.appendKeyValue("Explorer link", it.explorerLink)
builder.appendKeyValue("Outputs count", it.outputsCount)
infoHolder.tokens[it.blockchain]?.let { tokens ->
builder.append("Tokens:")
breakLine()
tokens.forEach { token ->
builder.appendKeyValue("Name", token.name)
builder.appendKeyValue("ID", token.id ?: "[custom token]")
builder.appendKeyValue("Name", token.name)
builder.appendKeyValue("Contract address", token.contractAddress)
}
}
builder.appendKeyValue("Host", it.host)
builder.appendKeyValue("Wallet address", it.addresses)
builder.appendKeyValue("Explorer link", it.explorerLink)
}
return this
}
@ -55,7 +57,7 @@ class FeedbackDataBuilder(
builder.appendKeyValue("Token", infoHolder.token)
builder.appendKeyValue("Error", error)
builder.appendDelimiter()
builder.appendKeyValue("Source address", walletInfo.address)
builder.appendKeyValue("Source address", walletInfo.addresses)
builder.appendKeyValue("Destination address", infoHolder.destinationAddress)
builder.appendKeyValue("Amount", infoHolder.amount)
builder.appendKeyValue("Fee", infoHolder.fee)
@ -72,8 +74,10 @@ class FeedbackDataBuilder(
fun build(): String = builder.toString()
}
private fun StringBuilder.appendKeyValue(key: String, value: String): StringBuilder {
return if (value.isNotBlank()) this.append("$key: $value\n") else this
private fun StringBuilder.appendKeyValue(key: String, value: String?): StringBuilder = when {
value.isNullOrBlank() -> this
key.isBlank() -> this.append("$value\n")
else -> this.append("$key: $value\n")
}
private fun StringBuilder.appendDelimiter(): StringBuilder = append("----------\n")

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

@ -12,19 +12,22 @@ interface StateDialog
sealed class AppDialog : StateDialog {
data class SimpleOkDialog(val header: String, val message: String, val onOk: VoidCallback? = null) : AppDialog()
data class SimpleOkErrorDialog(val message: String, val onOk: VoidCallback? = null) : AppDialog()
data class SimpleOkWarningDialog(val message: String, val onOk: VoidCallback? = null) : AppDialog()
data class SimpleOkDialogRes(
val headerId: Int,
val messageId: Int,
val onOk: VoidCallback? = null
val onOk: VoidCallback? = null,
) : AppDialog()
object ScanFailsDialog : AppDialog()
data class AddressInfoDialog(
val currency: Currency,
val addressData: AddressData,
) : AppDialog()
data class TestActionsDialog(
val actionsList: List<TestAction>
val actionsList: List<TestAction>,
) : AppDialog()
}

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,22 @@ 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() {
/**
* Initiate an onboarding process.
* For SaltPay cards it's additionally checks for unfinished backup.
* For resuming unfinished backup for standard Wallet cards see CheckForUnfinishedBackup and
* StartForUnfinishedBackup
*/
data class Start(val scanResponse: ScanResponse, val canSkipBackup: Boolean = true) : Onboarding()
/**
* Initiate resuming of unfinished backup only for standard Wallet cards.
* For SaltPay cards unfinished backup resumed after scanning the card on HomeScreen through Onboarding.Start.
* See more Onboarding.Start, CheckForUnfinishedBackup
*/
object StartForUnfinishedBackup : Onboarding()
object Stop : Onboarding()
}
data class ScanCard(
@ -78,7 +92,7 @@ sealed class GlobalAction : Action {
data class SetGlobalAnalyticsHandler(val analyticsHandler: GlobalAnalyticsEventHandler) : 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,30 @@ 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.scanResponse ?: globalState.onboardingState.onboardingManager?.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

@ -2,7 +2,14 @@ package com.tangem.tap.common.toggleWidget
import android.view.View
import android.view.ViewGroup
import android.view.animation.*
import android.view.animation.AccelerateInterpolator
import android.view.animation.AlphaAnimation
import android.view.animation.Animation
import android.view.animation.AnimationSet
import android.view.animation.AnticipateOvershootInterpolator
import android.view.animation.LinearInterpolator
import android.view.animation.RotateAnimation
import android.view.animation.ScaleAnimation
import android.widget.ViewSwitcher
import com.tangem.tap.features.wallet.redux.ProgressState
import com.tangem.wallet.R
@ -11,7 +18,7 @@ import com.tangem.wallet.R
[REDACTED_AUTHOR]
*/
class RefreshBalanceWidget(
private val root: ViewGroup
private val root: ViewGroup,
) : ViewStateWidget {
var isShowing: Boolean? = null
@ -19,7 +26,6 @@ class RefreshBalanceWidget(
override val mainView: View = root.findViewById(R.id.btn_refresh_balance)
private val viewSwitcher: ViewSwitcher by lazy { mainView.findViewById(R.id.switcher_refresh_arrow_to_progress) }
private val arrowView = mainView.findViewById<View>(R.id.imv_arrow_refresh)
@ -46,6 +52,7 @@ class RefreshBalanceWidget(
currentStateByView == ProgressState.Done -> if (progressState == ProgressState.Error) return
}
mainView.isClickable = currentStateByView == ProgressState.Done || currentStateByView == ProgressState.Error
animateState(progressState)
viewSwitcher.showNext()
}
@ -60,7 +67,7 @@ class RefreshBalanceWidget(
progressViewAnimation = RotateAnimation(
0f, 360f,
Animation.RELATIVE_TO_SELF, 0.5f,
Animation.RELATIVE_TO_SELF, 0.5f
Animation.RELATIVE_TO_SELF, 0.5f,
)
progressViewAnimation?.duration = 700
progressViewAnimation?.interpolator = AccelerateInterpolator()
@ -88,11 +95,13 @@ class RefreshBalanceWidget(
class ShowAnimation : AnimationSet(true) {
init {
addAnimation(ScaleAnimation(
0f, 1f,
0f, 1f,
Animation.RELATIVE_TO_SELF, 0.5f,
Animation.RELATIVE_TO_SELF, 0.5f)
addAnimation(
ScaleAnimation(
0f, 1f,
0f, 1f,
Animation.RELATIVE_TO_SELF, 0.5f,
Animation.RELATIVE_TO_SELF, 0.5f,
),
)
addAnimation(AlphaAnimation(0f, 1f))
duration = 300

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: GlobalAnalyticsEventHandler?,
result: CompletionResult<ScanResponse>
result: CompletionResult<ScanResponse>,
) {
when (result) {
is CompletionResult.Success -> {
analyticsHandler?.handleAnalyticsEvent(
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?.handleAnalyticsEvent(
event = AnalyticsEvent.DEMO_MODE_ACTIVATED,
params = mapOf(AnalyticsParam.CARD_ID.param to result.data.card.cardId),
card = result.data.card,
blockchain = result.data.walletData?.blockchain
blockchain = result.data.walletData?.blockchain,
)
}
}
@ -92,20 +93,23 @@ class TangemSdkManager(private val tangemSdk: TangemSdk, private val context: Co
(result.error as? TangemSdkError)?.let { error ->
analyticsHandler?.handleCardSdkErrorEvent(
error = error,
action = Analytics.ActionToLog.Scan
action = 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 +118,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 +183,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

@ -14,6 +14,7 @@ class TangemSigner(
private val card: Card,
private val tangemSdk: TangemSdk,
private val initialMessage: Message,
private val accessCode: String? = null,
private val signerCallback: (TangemSignerResponse) -> Unit,
) : TransactionSigner {
@ -29,6 +30,7 @@ class TangemSigner(
runnable = task,
cardId = cardId,
initialMessage = initialMessage,
accessCode = accessCode,
) { result ->
when (result) {
is CompletionResult.Success -> {

View file

@ -42,7 +42,7 @@ sealed class TapError(
data class UnsupportedState(
val stateError: String,
val customMessage: String = "Unsupported state:"
val customMessage: String = "Unsupported state:",
) : TapError(R.string.common_custom_string, listOf("$customMessage $stateError"))
sealed class WalletManager {
@ -53,9 +53,14 @@ sealed class TapError(
object BlockchainIsUnreachableTryLater : TapError(R.string.wallet_balance_blockchain_unreachable_try_later)
}
sealed class WalletConnect {
object UnsupportedDapp : TapError(R.string.wallet_connect_error_unsupported_dapp)
object UnsupportedLink : TapError(R.string.wallet_connect_error_failed_to_connect)
}
data class ValidateTransactionErrors(
override val errorList: List<TapError>,
override val builder: (List<String>) -> String
override val builder: (List<String>) -> String,
) : TapError(-1), MultiMessageError
}

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)
@ -132,42 +134,10 @@ class TapWalletManager {
}
dispatchOnMain(WalletAction.LoadWallet())
dispatchOnMain(WalletAction.LoadFiatRate())
}
private suspend fun loadMultiWalletData(
scanResponse: ScanResponse,
) {
when (val tokensResult = userTokensRepository.getUserTokens(scanResponse.card)) {
is Result.Success -> {
withMainContext {
val blockchainNetworks = tokensResult.data.toBlockchainNetworks()
val walletManagers = walletManagerFactory.makeWalletManagersForApp(scanResponse, tokensResult.data)
store.dispatch(
WalletAction.MultiWallet.AddBlockchains(
blockchains = blockchainNetworks,
walletManagers = walletManagers,
save = false,
),
)
blockchainNetworks.filter { it.tokens.isNotEmpty() }
.map {
store.dispatch(
WalletAction.MultiWallet.AddTokens(
tokens = it.tokens,
blockchain = it,
save = false,
),
)
}
checkIfDerivationsAreMissing(blockchainNetworks, scanResponse)
}
}
is Result.Failure -> {
return
}
}
private suspend fun loadMultiWalletData(scanResponse: ScanResponse) {
loadUserCurrencies(scanResponse, walletManagerFactory)
}
private fun checkIfDerivationsAreMissing(blockchainNetworks: List<BlockchainNetwork>, scanResponse: ScanResponse) {
@ -203,11 +173,41 @@ class TapWalletManager {
walletManagers = listOf(primaryWalletManager),
save = false,
),
WalletAction.LoadFiatRate(),
)
}
}
private suspend fun loadUserCurrencies(scanResponse: ScanResponse, walletManagerFactory: WalletManagerFactory) {
val userTokens = userTokensRepository.getUserTokens(scanResponse.card)
withMainContext {
val blockchainNetworks = userTokens.toBlockchainNetworks()
val walletManagers = walletManagerFactory.makeWalletManagersForApp(scanResponse, userTokens)
store.dispatch(
WalletAction.MultiWallet.AddBlockchains(
blockchains = blockchainNetworks,
walletManagers = walletManagers,
save = false,
),
)
blockchainNetworks.filter { it.tokens.isNotEmpty() }
.map {
store.dispatch(
WalletAction.MultiWallet.AddTokens(
tokens = it.tokens,
blockchain = it,
save = false,
),
)
}
checkIfDerivationsAreMissing(blockchainNetworks, scanResponse)
store.dispatch(WalletAction.LoadFiatRate(coinsList = userTokens))
}
}
suspend fun reloadData(data: ScanResponse) {
loadUserCurrencies(data, walletManagerFactory)
withContext(Dispatchers.Main) {
getActionIfUnknownBlockchainOrEmptyWallet(data)?.let {
store.dispatch(it)
@ -219,7 +219,6 @@ class TapWalletManager {
}
store.dispatch(WalletAction.LoadWallet())
store.dispatch(WalletAction.LoadFiatRate())
}
}

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]
@ -23,6 +24,7 @@ data class Config(
val isCreatingTwinCardsAllowed: Boolean = false,
val shopify: ShopifyShop? = null,
val zendesk: ZendeskConfig? = null,
val saltPayConfig: SaltPayConfig? = null,
)
class ConfigManager {
@ -92,6 +94,7 @@ class ConfigManager {
amplitudeApiKey = values.amplitudeApiKey,
shopify = values.shopifyShop,
zendesk = values.zendesk,
saltPayConfig = values.saltPay,
)
defaultConfig = defaultConfig.copy(
coinMarketCapKey = values.coinMarketCapKey,
@ -109,6 +112,7 @@ class ConfigManager {
amplitudeApiKey = values.amplitudeApiKey,
shopify = values.shopifyShop,
zendesk = values.zendesk,
saltPayConfig = values.saltPay,
)
}

View file

@ -2,6 +2,7 @@ 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]
@ -27,6 +28,7 @@ class ConfigValueModel(
val appsFlyerDevKey: String,
val shopifyShop: ShopifyShop?,
val zendesk: ZendeskConfig?,
val saltPay: SaltPayConfig,
val tronGridApiKey: String,
val amplitudeApiKey: String,
)

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.SaltPayActivationErrorConverter
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayActivationError
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayModuleError
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayModuleMessage
/**
[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 SaltPayActivationError -> SaltPayActivationErrorConverter(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.SaltPayActivationError
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
internal class SaltPayActivationErrorConverter(
private val context: Context,
) : ModuleMessageConverter<SaltPayActivationError, ConvertedDialogMessage?> {
override fun convert(message: SaltPayActivationError): ConvertedDialogMessage? {
val saltPayError = (message as? SaltPayActivationError) ?: throw UnsupportedOperationException()
val dialogMessage = when (saltPayError) {
SaltPayActivationError.NoGas -> {
ConvertedDialogMessage(
title = context.getString(R.string.saltpay_error_no_gas_title),
message = context.getString(R.string.saltpay_error_no_gas_message),
)
}
SaltPayActivationError.EmptyBackupCardScanned -> {
ConvertedDialogMessage(
title = context.getString(R.string.saltpay_error_empty_backup_title),
message = context.getString(R.string.saltpay_error_empty_backup_message),
)
}
SaltPayActivationError.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 == SaltPayActivationError.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,11 @@ 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.isSaltPay
import com.tangem.domain.common.TapWorkarounds.isTangemNote
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,9 +32,12 @@ 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
import com.tangem.tap.scope
import kotlinx.coroutines.launch
class ScanProductTask(
val card: Card? = null,
@ -58,8 +61,8 @@ class ScanProductTask(
}
val commandProcessor = when {
card.isTangemNote() -> ScanNoteProcessor()
card.isTangemTwins() -> ScanTwinProcessor()
card.isTangemNote -> ScanNoteProcessor()
card.isTangemTwins -> ScanTwinProcessor()
else -> ScanWalletProcessor(userTokensRepository, additionalBlockchainsToDerive)
}
commandProcessor.proceed(card, session) { processorResult ->
@ -83,9 +86,8 @@ class ScanProductTask(
}
private fun getErrorIfExcludedCard(card: Card): TangemError? {
if (card.isExcluded()) return TapSdkError.CardForDifferentApp
if (card.isNotSupportedInThatRelease()) return TapSdkError.CardNotSupportedByRelease
if (card.isExcluded) return TapSdkError.CardForDifferentApp
if (card.isNotSupportedInThatRelease) return TapSdkError.CardNotSupportedByRelease
return null
}
}
@ -121,12 +123,13 @@ 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) {
startLinkingForBackupIfNeeded(card, session, callback)
return
}
@ -182,39 +185,45 @@ private class ScanWalletProcessor(
session: CardSession,
callback: (result: CompletionResult<ScanResponse>) -> Unit,
) {
val derivations = collectDerivations(card)
if (derivations.isEmpty() || !card.settings.isHDWalletAllowed) {
callback(
CompletionResult.Success(
ScanResponse(
card = card,
productType = ProductType.Wallet,
walletData = session.environment.walletData,
primaryCard = primaryCard,
),
),
)
return
val productType = when (card.isSaltPay) {
true -> ProductType.SaltPay
else -> ProductType.Wallet
}
scope.launch {
val derivations = collectDerivations(card)
if (derivations.isEmpty() || !card.settings.isHDWalletAllowed) {
callback(
CompletionResult.Success(
ScanResponse(
card = card,
productType = productType,
walletData = session.environment.walletData,
primaryCard = primaryCard,
),
),
)
return@launch
}
DeriveMultipleWalletPublicKeysTask(derivations).run(session) { result ->
when (result) {
is CompletionResult.Success -> {
val response = ScanResponse(
card = card,
productType = ProductType.Wallet,
walletData = session.environment.walletData,
derivedKeys = result.data.entries,
primaryCard = primaryCard,
)
callback(CompletionResult.Success(response))
DeriveMultipleWalletPublicKeysTask(derivations).run(session) { result ->
when (result) {
is CompletionResult.Success -> {
val response = ScanResponse(
card = card,
productType = productType,
walletData = session.environment.walletData,
derivedKeys = result.data.entries,
primaryCard = primaryCard,
)
callback(CompletionResult.Success(response))
}
is CompletionResult.Failure -> callback(CompletionResult.Failure(result.error))
}
is CompletionResult.Failure -> callback(CompletionResult.Failure(result.error))
}
}
}
private fun getBlockchainsToDerive(card: Card): List<BlockchainNetwork> {
private suspend fun getBlockchainsToDerive(card: Card): List<BlockchainNetwork> {
val userTokensRepository = userTokensRepository ?: return emptyList()
val blockchainsToDerive = userTokensRepository.loadBlockchainsToDerive(card).toMutableList().ifEmpty {
mutableListOf(
@ -248,7 +257,7 @@ private class ScanWalletProcessor(
return blockchainsToDerive.distinct()
}
private fun collectDerivations(card: Card): Map<ByteArrayKey, List<DerivationPath>> {
private suspend fun collectDerivations(card: Card): Map<ByteArrayKey, List<DerivationPath>> {
val blockchains = getBlockchainsToDerive(card)
val derivations = mutableMapOf<ByteArrayKey, List<DerivationPath>>()
@ -256,6 +265,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

@ -5,7 +5,6 @@ import com.tangem.common.services.Result
import com.tangem.network.api.tangemTech.TangemTechService
import com.tangem.network.api.tangemTech.UserTokensResponse
import com.tangem.tap.domain.NoDataError
import com.tangem.tap.features.wallet.models.Currency
class UserTokensNetworkService(private val tangemTechService: TangemTechService) {
suspend fun getUserTokens(userId: String): Result<UserTokensResponse> {
@ -22,9 +21,7 @@ class UserTokensNetworkService(private val tangemTechService: TangemTechService)
}
}
suspend fun saveUserTokens(userId: String, tokens: List<Currency>): Result<Unit> {
val tokensResponse = tokens.map { it.toTokenResponse() }
val data = UserTokensResponse(tokens = tokensResponse)
return tangemTechService.putUserTokens(userId, data)
suspend fun saveUserTokens(userId: String, tokens: UserTokensResponse): Result<Unit> {
return tangemTechService.putUserTokens(userId, tokens)
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.common.extensions.toHexString
import com.tangem.common.services.Result
import com.tangem.domain.common.extensions.calculateHmacSha256
import com.tangem.network.api.tangemTech.TangemTechService
import com.tangem.network.api.tangemTech.UserTokensResponse
import com.tangem.tap.common.AndroidFileReader
import com.tangem.tap.domain.NoDataError
import com.tangem.tap.domain.tokens.models.BlockchainNetwork
@ -24,20 +25,21 @@ class UserTokensRepository(
private val storageService: UserTokensStorageService,
private val networkService: UserTokensNetworkService,
) {
suspend fun getUserTokens(card: Card): Result<List<Currency>> {
if (DemoHelper.isDemoCardId(card.cardId)) {
return Result.Success(loadDemoCurrencies())
}
suspend fun getUserTokens(card: Card): List<Currency> {
val userId = card.getUserId()
if (DemoHelper.isDemoCardId(card.cardId)) {
return loadTokensOffline(card, userId).ifEmpty { loadDemoCurrencies() }
}
if (!NetworkConnectivity.getInstance().isOnlineOrConnecting()) {
return Result.Success(loadTokensOffline(card, userId))
return loadTokensOffline(card, userId)
}
return when (val networkResult = networkService.getUserTokens(userId)) {
is Result.Success -> {
val tokens = networkResult.data.tokens.map { Currency.fromTokenResponse(it) }
storageService.saveUserTokens(card.getUserId(), tokens)
Result.Success(tokens)
val tokens = networkResult.data.tokens.mapNotNull { Currency.fromTokenResponse(it) }
storageService.saveUserTokens(card.getUserId(), tokens.toUserTokensResponse())
tokens.distinct()
}
is Result.Failure -> {
handleGetUserTokensFailure(card = card, userId = userId, error = networkResult.error)
@ -46,17 +48,36 @@ class UserTokensRepository(
}
suspend fun saveUserTokens(card: Card, tokens: List<Currency>) {
networkService.saveUserTokens(card.getUserId(), tokens)
storageService.saveUserTokens(card.getUserId(), tokens)
val userTokens = tokens.toUserTokensResponse()
networkService.saveUserTokens(card.getUserId(), userTokens)
storageService.saveUserTokens(card.getUserId(), userTokens)
}
suspend fun removeUserTokens(card: Card) {
networkService.saveUserTokens(card.getUserId(), emptyList())
storageService.saveUserTokens(card.getUserId(), emptyList())
val userTokens = emptyList<Currency>().toUserTokensResponse()
networkService.saveUserTokens(card.getUserId(), userTokens)
storageService.saveUserTokens(card.getUserId(), userTokens)
}
fun loadBlockchainsToDerive(card: Card): List<BlockchainNetwork> {
return storageService.getUserTokens(card.getUserId())?.toBlockchainNetworks() ?: emptyList()
private fun List<Currency>.toUserTokensResponse(): UserTokensResponse {
val tokensResponse = this.map { it.toTokenResponse() }
return UserTokensResponse(
tokens = tokensResponse,
group = GROUP_DEFAULT_VALUE,
sort = SORT_DEFAULT_VALUE,
)
}
suspend fun loadBlockchainsToDerive(card: Card): List<BlockchainNetwork> {
val userId = card.getUserId()
val blockchainNetworks = loadTokensOffline(card, userId).toBlockchainNetworks()
if (DemoHelper.isDemoCardId(card.cardId)) {
return blockchainNetworks
.ifEmpty { loadDemoCurrencies().toBlockchainNetworks() }
}
return blockchainNetworks
}
private fun loadDemoCurrencies(): List<Currency> {
@ -73,16 +94,17 @@ class UserTokensRepository(
card: Card,
userId: String,
error: Throwable,
): Result<List<Currency>> {
): List<Currency> {
return when (error) {
is NoDataError -> {
val tokens = storageService.getUserTokens(card)
coroutineScope { launch { networkService.saveUserTokens(userId = userId, tokens = tokens) } }
Result.Success(tokens)
val userTokens = tokens.toUserTokensResponse()
coroutineScope { launch { networkService.saveUserTokens(userId = userId, tokens = userTokens) } }
tokens
}
else -> {
val tokens = storageService.getUserTokens(userId) ?: storageService.getUserTokens(card)
Result.Success(tokens)
tokens.distinct()
}
}
}
@ -93,17 +115,12 @@ 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 = "AccountID"
const val SORT_DEFAULT_VALUE = "manual"
const val GROUP_DEFAULT_VALUE = "none"
fun init(context: Context, tangemTechService: TangemTechService): UserTokensRepository {
val fileReader = AndroidFileReader(context)
val oldUserTokensRepository = OldUserTokensRepository(
@ -114,4 +131,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

@ -20,7 +20,7 @@ class UserTokensStorageService(
fun getUserTokens(userId: String): List<Currency>? {
return try {
val json = fileReader.readFile(getFileNameForUserTokens(userId))
userTokensAdapter.fromJson(json)?.tokens?.map { Currency.fromTokenResponse(it) }
userTokensAdapter.fromJson(json)?.tokens?.mapNotNull { Currency.fromTokenResponse(it) }
} catch (exception: Exception) {
Log.error { exception.stackTraceToString() }
null
@ -34,10 +34,8 @@ class UserTokensStorageService(
return blockchainNetworks.flatMap { it.toCurrencies() }
}
fun saveUserTokens(userId: String, tokens: List<Currency>) {
val tokensResponse = tokens.map { it.toTokenResponse() }
val data = UserTokensResponse(tokens = tokensResponse)
val json = userTokensAdapter.toJson(data)
fun saveUserTokens(userId: String, tokens: UserTokensResponse) {
val json = userTokensAdapter.toJson(tokens)
fileReader.rewriteFile(json, getFileNameForUserTokens(userId))
}

View file

@ -8,6 +8,8 @@ import com.tangem.tap.common.analytics.Analytics
import com.tangem.tap.common.analytics.logWcEvent
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.walletconnect.extensions.isDappSupported
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectDialog
import com.tangem.tap.features.details.redux.walletconnect.WalletConnectSession
@ -58,7 +60,15 @@ class WalletConnectManager {
private var sessions: MutableMap<WCSession, WalletConnectActiveData> = mutableMapOf()
fun connect(wcUri: String) {
val session = WCSession.from(wcUri) ?: return
val session = WCSession.from(wcUri).guard {
store.dispatchOnMain(
WalletConnectAction.FailureEstablishingSession(
session = null,
error = TapError.WalletConnect.UnsupportedLink,
),
)
return
}
if (sessions[session] != null) {
store.dispatchOnMain(WalletConnectAction.RefuseOpeningSession)
return
@ -66,7 +76,19 @@ class WalletConnectManager {
val client = WCClient(httpClient = okHttpClient)
setListeners(client)
val peerId = UUID.randomUUID().toString()
client.connect(session, tangemPeerMeta, peerId)
try {
client.connect(session, tangemPeerMeta, peerId)
} catch (exception: IllegalArgumentException) {
store.dispatchOnMain(
WalletConnectAction.FailureEstablishingSession(
session = null,
error = TapError.WalletConnect.UnsupportedLink,
),
)
return
}
sessions[session] = WalletConnectActiveData(
peerId = peerId,
remotePeerId = null,
@ -332,21 +354,30 @@ class WalletConnectManager {
val session = client.session
val data = sessions[session]?.copy(peerMeta = peer, remotePeerId = client.remotePeerId)
if (data != null && session != null) {
sessions[session] = data
val sessionData = data.toWalletConnectSession()
sessionData?.let {
if (!peer.isDappSupported()) {
store.dispatchOnMain(
WalletConnectAction.ScanCard(
session = sessionData,
chainId = client.chainId?.toIntOrNull()
WalletConnectAction.FailureEstablishingSession(
session = session,
error = TapError.WalletConnect.UnsupportedDapp,
),
)
} else {
sessions[session] = data
val sessionData = data.toWalletConnectSession()
sessionData?.let {
store.dispatchOnMain(
WalletConnectAction.ScanCard(
session = sessionData,
chainId = client.chainId?.toIntOrNull(),
),
)
}
store.state.globalState.analyticsHandler?.logWcEvent(
Analytics.WcAnalyticsEvent.Session(
Analytics.WcSessionEvent.Connect, peer.url,
),
)
}
store.state.globalState.analyticsHandler?.logWcEvent(
Analytics.WcAnalyticsEvent.Session(
Analytics.WcSessionEvent.Connect, peer.url
)
)
}
}
client.onSessionUpdate = { id: Long, update: WCSessionUpdate ->

View file

@ -0,0 +1,9 @@
package com.tangem.tap.domain.walletconnect.extensions
import com.trustwallet.walletconnect.models.WCPeerMeta
fun WCPeerMeta.isDappSupported(): Boolean {
return !unsupportedDappsList.any { this.url.contains(it) }
}
private val unsupportedDappsList: List<String> = listOf("dydx.exchange")

View file

@ -7,7 +7,10 @@ import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.Toolbar
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.transition.TransitionInflater
import com.google.android.material.snackbar.Snackbar
import com.tangem.common.extensions.VoidCallback
@ -42,6 +45,17 @@ abstract class BaseFragment(layoutId: Int) : Fragment(layoutId), FragmentOnBackP
store.dispatch(NavigationAction.PopBackTo())
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loadToolbarMenu()?.let {
val menuHost = requireActivity() as? MenuHost ?: return
menuHost.addMenuProvider(it, viewLifecycleOwner, Lifecycle.State.RESUMED)
}
}
protected open fun loadToolbarMenu():MenuProvider? = null
fun showRetrySnackbar(message: String, action: VoidCallback) {
val snackbar = Snackbar.make(mainView, message, Snackbar.LENGTH_INDEFINITE)
snackbar.setAction(getString(R.string.common_retry)) {

View file

@ -448,6 +448,33 @@ class DemoConfig {
"AB02000000049533",
"AB02000000049541",
"AB02000000049830",
// === more cids ===
"AC03000000091418",
"AC03000000091400",
"AC03000000099007",
"AC03000000098991",
"AC03000000098942",
"AC03000000091715",
"AC03000000091301",
"AC03000000091343",
"AB01000000055705",
"AB01000000052918",
"AB01000000047710",
"AB01000000052306",
"AB01000000047645",
"AB01000000048957",
"AB01000000052900",
"AB01000000050391",
"AB01000000047363",
"AB02000000053998",
"AB02000000019809",
"AB02000000020872",
"AB02000000022027",
"AB02000000058955",
"AB02000000053253",
"AB02000000048063",
"AB02000000023736",
"AB02000000058187",
)
private val testDemoCardIds = listOf(

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

@ -3,6 +3,7 @@ package com.tangem.tap.features.details.redux.walletconnect
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.common.ScanResponse
import com.tangem.tap.common.redux.NotificationAction
import com.tangem.tap.domain.TapError
import com.tangem.wallet.R
import com.trustwallet.walletconnect.models.binance.WCBinanceTradeOrder
import com.trustwallet.walletconnect.models.binance.WCBinanceTransferOrder
@ -52,7 +53,7 @@ sealed class WalletConnectAction : Action {
val updatedSession: WalletConnectSession,
) : WalletConnectAction()
data class FailureEstablishingSession(val session: WCSession?) : WalletConnectAction()
data class FailureEstablishingSession(val session: WCSession?, val error: TapError? = null) : WalletConnectAction()
data class SetSessionsRestored(val sessions: List<WalletConnectSession>) :
WalletConnectAction()
@ -103,6 +104,6 @@ sealed class WalletConnectAction : Action {
data class Sign(
val id: Long, val data: ByteArray, val sessionData: WCSession,
) : WalletConnectAction()
) : WalletConnectAction()
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.TapWorkarounds.derivationStyle
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.tap.common.extensions.dispatchOnMain
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
@ -22,6 +23,7 @@ import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.features.wallet.redux.WalletState
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.wallet.R
import kotlinx.coroutines.launch
import org.rekotlin.Action
import org.rekotlin.Middleware
@ -96,6 +98,16 @@ class WalletConnectMiddleware {
store.dispatchOnMain(GlobalAction.ShowDialog(WalletConnectDialog.SessionTimeout))
}
is WalletConnectAction.FailureEstablishingSession -> {
if (action.error != null) {
store.dispatch(
GlobalAction.ShowDialog(
AppDialog.SimpleOkDialogRes(
headerId = R.string.common_warning,
messageId = action.error.messageResource,
),
),
)
}
if (action.session != null) {
walletConnectManager.disconnect(action.session)
}
@ -306,9 +318,11 @@ class WalletConnectMiddleware {
} else {
blockchain
}
val derivation = blockchainToMake.derivationPath(store.state.globalState.scanResponse?.card?.derivationStyle)
?.rawPath
val blockchainNetwork = BlockchainNetwork(
blockchain = blockchainToMake,
derivationPath = wallet.derivationPath?.rawPath,
derivationPath = derivation,
tokens = emptyList(),
)
return walletState.getWalletManager(blockchainNetwork)

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

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -86,7 +87,7 @@ fun WalletConnectDetailsItem(
) {
Row(
modifier = modifier
.height(84.dp)
.defaultMinSize(minHeight = 84.dp)
.fillMaxWidth()
.clickable { onItemsClick(SettingsElement.WalletConnect) },
horizontalArrangement = Arrangement.Start,
@ -99,7 +100,7 @@ fun WalletConnectDetailsItem(
tint = colorResource(id = R.color.all_colors_azure),
)
Column(
modifier = modifier.height(56.dp),
modifier = modifier.defaultMinSize(minHeight = 56.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Center,
) {

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
@ -75,8 +78,7 @@ class DetailsViewModel(private val store: Store<AppState>) {
store.dispatch(DetailsAction.CreateBackup)
}
SettingsElement.TermsOfService -> {
store.dispatch(DisclaimerAction.ShowAcceptedDisclaimer)
store.dispatch(NavigationAction.NavigateTo(AppScreen.Disclaimer))
store.dispatch(DisclaimerAction.Show())
}
SettingsElement.TermsOfUse -> {
store.dispatch(DetailsAction.ShowDisclaimer)

View file

@ -1,6 +1,8 @@
package com.tangem.tap.features.details.ui.resetcard
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -89,11 +91,14 @@ fun ResetCardView(
color = colorResource(id = R.color.text_secondary),
)
Spacer(modifier = modifier.size(44.dp))
Spacer(modifier = modifier.size(28.dp))
Row(
modifier = modifier
.fillMaxWidth()
.padding(end = 20.dp),
.clickable(
onClick = { state.onAcceptWarningToggleClick(!state.accepted) },
)
.padding(top = 16.dp, bottom = 16.dp),
) {
IconToggleButton(
checked = state.accepted,
@ -120,13 +125,15 @@ fun ResetCardView(
text = stringResource(id = R.string.reset_card_to_factory_warning_message),
style = TangemTypography.body2,
color = colorResource(id = R.color.text_secondary),
modifier = modifier
.padding(end = 20.dp),
)
}
Spacer(modifier = modifier.size(32.dp))
Spacer(modifier = modifier.size(16.dp))
Box(
modifier = modifier
.padding(start = 16.dp, end = 16.dp),
.padding(start = 16.dp, end = 16.dp, bottom = 32.dp),
) {
DetailsMainButton(
title = stringResource(id = R.string.reset_card_to_factory_button_title),

View file

@ -140,13 +140,12 @@ private fun WalletConnectSessions(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = modifier) {
Text(
text = session.description,
style = TangemTypography.subtitle1,
color = colorResource(id = R.color.text_primary_1),
)
}
Text(
text = session.description,
style = TangemTypography.subtitle1,
color = colorResource(id = R.color.text_primary_1),
modifier = modifier.weight(1f),
)
IconButton(
onClick = { state.onRemoveSession(session.sessionId) },
) {

View file

@ -1,8 +1,17 @@
package com.tangem.tap.features.disclaimer.redux
import com.tangem.common.extensions.VoidCallback
import org.rekotlin.Action
sealed class DisclaimerAction: Action {
object AcceptDisclaimer : DisclaimerAction()
object ShowAcceptedDisclaimer : DisclaimerAction()
sealed class DisclaimerAction : Action {
data class SetDisclaimerType(
val type: DisclaimerType,
) : DisclaimerAction()
data class Show(val onAcceptCallback: VoidCallback? = null) : DisclaimerAction()
data class AcceptDisclaimer(val type: DisclaimerType) : DisclaimerAction()
internal data class UpdateState(val type: DisclaimerType, val accepted: Boolean) : DisclaimerAction()
internal data class SetOnAcceptCallback(val onAcceptCallback: VoidCallback? = null) : DisclaimerAction()
}

View file

@ -1,28 +1,65 @@
package com.tangem.tap.features.disclaimer.redux
import com.tangem.tap.common.post
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.features.home.redux.HomeAction
import com.tangem.tap.preferencesStorage
import com.tangem.tap.store
import org.rekotlin.Action
import org.rekotlin.Middleware
class DisclaimerMiddleware {
val disclaimerMiddleware: Middleware<AppState> = { dispatch, state ->
{ next ->
{ action ->
when (action) {
is DisclaimerAction.AcceptDisclaimer -> {
preferencesStorage.saveDisclaimerAccepted()
store.dispatch(NavigationAction.PopBackTo())
//delayed sending used for better animation
post(550) { store.dispatch(HomeAction.ReadCard) }
}
}
state()?.let { handleDisclaimerMiddleware(action, it) }
next(action)
}
}
}
}
}
private fun handleDisclaimerMiddleware(action: Action, appState: AppState) {
val state = appState.disclaimerState
when (action) {
is DisclaimerAction.SetDisclaimerType -> {
handleUpdateState = action.type.createUpdateState()
}
is DisclaimerAction.Show -> {
handleUpdateState = state.type.createUpdateState()
store.dispatch(DisclaimerAction.SetOnAcceptCallback(action.onAcceptCallback))
store.dispatch(NavigationAction.NavigateTo(AppScreen.Disclaimer))
}
is DisclaimerAction.AcceptDisclaimer -> {
action.type.accept()
handleUpdateState = action.type.createUpdateState()
store.dispatch(NavigationAction.PopBackTo())
state.onAcceptCallback?.invoke()
store.dispatch(DisclaimerAction.SetOnAcceptCallback(null))
}
}
}
private fun DisclaimerType.accept() = when (this) {
DisclaimerType.SaltPay -> preferencesStorage.disclaimerPrefStorage.hasSaltPayTosAccepted = true
DisclaimerType.Tangem -> preferencesStorage.disclaimerPrefStorage.hasTangemTosAccepted = true
}
fun DisclaimerType.isAccepted(): Boolean = when (this) {
DisclaimerType.SaltPay -> preferencesStorage.disclaimerPrefStorage.hasSaltPayTosAccepted
DisclaimerType.Tangem -> preferencesStorage.disclaimerPrefStorage.hasTangemTosAccepted
}
private fun DisclaimerType.createUpdateState(): DisclaimerAction.UpdateState {
return DisclaimerAction.UpdateState(this, this.isAccepted())
}
private var handleUpdateState: DisclaimerAction.UpdateState = DisclaimerAction.UpdateState(
type = DisclaimerType.Tangem,
accepted = false,
)
set(value) {
field = value
store.dispatch(value)
}

View file

@ -13,9 +13,15 @@ private fun internalReduce(action: Action, state: AppState): DisclaimerState {
if (action !is DisclaimerAction) return state.disclaimerState
val disclaimerState = state.disclaimerState
return when (action) {
is DisclaimerAction.AcceptDisclaimer, DisclaimerAction.ShowAcceptedDisclaimer -> {
disclaimerState.copy(accepted = true)
}
is DisclaimerAction.UpdateState -> disclaimerState.copy(
type = action.type,
accepted = action.accepted,
)
is DisclaimerAction.SetOnAcceptCallback -> disclaimerState.copy(
onAcceptCallback = action.onAcceptCallback,
)
else -> disclaimerState
}
}

View file

@ -1,7 +1,30 @@
package com.tangem.tap.features.disclaimer.redux
import android.net.Uri
import com.tangem.common.card.Card
import com.tangem.common.extensions.VoidCallback
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.TapWorkarounds.isSaltPay
import org.rekotlin.StateType
data class DisclaimerState(
val accepted: Boolean = false
val accepted: Boolean = false,
val type: DisclaimerType = DisclaimerType.Tangem,
val onAcceptCallback: VoidCallback? = null,
) : StateType
sealed class DisclaimerType(
val uri: Uri,
) {
object Tangem : DisclaimerType(Uri.parse("https://tangem.com/tangem_tos.html"))
object SaltPay : DisclaimerType(Uri.parse("https://tangem.com/soltpay_tos.html"))
companion object {
fun get(scanResponse: ScanResponse): DisclaimerType = get(scanResponse.card)
fun get(card: Card): DisclaimerType = when {
card.isSaltPay -> SaltPay
else -> Tangem
}
}
}

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,19 +55,25 @@ class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer),
binding.toolbar.setNavigationOnClickListener {
store.dispatch(NavigationAction.PopBackTo())
}
setOnClickListeners()
initViews()
}
private fun setOnClickListeners() {
binding.btnAccept.setOnClickListener { store.dispatch(DisclaimerAction.AcceptDisclaimer) }
private fun initViews() {
binding.webView.configureSettings()
}
override fun newState(state: DisclaimerState) {
override fun newState(state: DisclaimerState) = with(binding) {
if (activity == null || view == null) return
if (state.accepted) {
binding.halfTransparentOverlay.hide()
binding.btnAccept.hide()
halfTransparentOverlay.hide()
btnAccept.hide()
}
btnAccept.setOnClickListener {
store.dispatch(DisclaimerAction.AcceptDisclaimer(state.type))
}
webView.loadUrl(state.type.uri.toString())
}
}

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,13 +6,12 @@ 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
data class ShouldScanCardOnResume(val shouldScanCard: Boolean) : HomeAction()
object Init : HomeAction()
data class SetTermsOfUseState(val isDisclaimerAccepted: Boolean) : HomeAction()
data class ChangeScanCardButtonState(val state: IndeterminateProgressButton) : HomeAction()
}

View file

@ -1,31 +1,47 @@
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.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
import com.tangem.tap.common.redux.navigation.FragmentShareTransition
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.features.disclaimer.redux.DisclaimerAction
import com.tangem.tap.features.disclaimer.redux.DisclaimerType
import com.tangem.tap.features.disclaimer.redux.isAccepted
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.SaltPayExceptionHandler
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 +52,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.analyticsHandler?.handleAnalyticsEvent(
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))
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(GlobalAction.FetchUserCountry)
}
is HomeAction.ShouldScanCardOnResume -> {
if (action.shouldScanCard) {
store.dispatch(HomeAction.ShouldScanCardOnResume(false))
postUiDelayBg(700) { store.dispatch(HomeAction.ReadCard) }
}
}
is HomeAction.ReadCard -> {
changeButtonState(ButtonState.PROGRESS)
val scanCardAction = GlobalAction.ScanCard(
onSuccess = {
store.dispatch(HomeAction.ScanInProgress(false))
checkForDisclaimer(it, ::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),
)
}
}
}
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 checkForDisclaimer(scanResponse: ScanResponse, onDisclaimerAccepted: (ScanResponse) -> Unit) {
val disclaimerType = DisclaimerType.get(scanResponse)
store.dispatch(DisclaimerAction.SetDisclaimerType(disclaimerType))
if (disclaimerType.isAccepted()) {
onDisclaimerAccepted((scanResponse))
} else {
changeButtonState(ButtonState.ENABLED)
store.dispatch(
DisclaimerAction.Show {
changeButtonState(ButtonState.PROGRESS)
onDisclaimerAccepted(scanResponse)
},
)
}
}
private fun onScanSuccess(scanResponse: ScanResponse) {
val globalState = store.state.globalState
val tapWalletManager = globalState.tapWalletManager
tapWalletManager.updateConfigManager(scanResponse)
store.dispatch(TwinCardsAction.IfTwinsPrepareState(scanResponse))
if (scanResponse.isSaltPay()) {
if (scanResponse.isSaltPayVisa()) {
scope.launch {
val (manager, config) = OnboardingSaltPayState.initDependency(scanResponse)
val result = OnboardingSaltPayHelper.isOnboardingCase(scanResponse, manager)
delay(500)
withMainContext {
when (result) {
is Result.Success -> {
val isOnboardingCase = result.data
if (isOnboardingCase) {
store.dispatch(GlobalAction.Onboarding.Start(scanResponse, canSkipBackup = false))
store.dispatch(OnboardingSaltPayAction.Init.SetDependencies(manager, config))
store.dispatch(OnboardingSaltPayAction.Update)
navigateTo(AppScreen.OnboardingWallet)
} else {
navigateTo(AppScreen.Wallet)
withIOContext { store.onCardScanned(scanResponse) }
}
}
is Result.Failure -> {
changeButtonState(ButtonState.ENABLED)
SaltPayExceptionHandler.handle(result.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,27 @@
package com.tangem.tap.features.onboarding
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
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 OnboardingMenuProvider : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.menu_onboarding, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.menu_item_chat_support -> {
store.dispatch(GlobalAction.OpenChat(SupportInfo()))
true
}
else -> false
}
}

View file

@ -0,0 +1,47 @@
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.SaltPayActivationManager
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayActivationError
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.SaltPayActivationStep
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.updateActivationStatus
/**
[REDACTED_AUTHOR]
*/
class OnboardingSaltPayHelper {
companion object {
suspend fun isOnboardingCase(
scanResponse: ScanResponse,
manager: SaltPayActivationManager,
): Result<Boolean> {
return try {
val status = manager.updateActivationStatus(
amountToClaim = null,
step = SaltPayActivationStep.None,
).successOr { return it }
val isRegistrationCase = status.step != SaltPayActivationStep.Finished
val isBackupCase = scanResponse.card.backupStatus?.isActive == false
Result.Success(isRegistrationCase || isBackupCase)
} catch (ex: Exception) {
Result.Failure(ex)
} catch (error: SaltPayActivationError) {
Result.Failure(error)
}
}
suspend fun testProceedToOnboarding(
scanResponse: ScanResponse,
manager: SaltPayActivationManager,
): Result<Boolean> = Result.Success(true)
suspend fun testProceedToMainScreen(
scanResponse: ScanResponse,
manager: SaltPayActivationManager,
): Result<Boolean> = Result.Success(false)
}
}

View file

@ -1,9 +1,14 @@
package com.tangem.tap.features.onboarding.products
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.MenuProvider
import by.kirich1409.viewbindingdelegate.viewBinding
import com.tangem.tap.common.extensions.show
import com.tangem.tap.common.transitions.HomeToOnboardingTransition
import com.tangem.tap.features.BaseStoreFragment
import com.tangem.tap.features.onboarding.OnboardingMenuProvider
import com.tangem.wallet.R
import com.tangem.wallet.databinding.FragmentOnboardingMainBinding
import com.tangem.wallet.databinding.ViewOnboardingProgressBinding
@ -22,6 +27,12 @@ abstract class BaseOnboardingFragment<T> : BaseStoreFragment(R.layout.fragment_o
exitTransition = HomeToOnboardingTransition()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
(activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar)
}
override fun loadToolbarMenu(): MenuProvider? = OnboardingMenuProvider()
protected fun showConfetti(show: Boolean) = with(binding.vConfetti) {
lavConfetti.show(show)

View file

@ -1,19 +1,14 @@
package com.tangem.tap.features.onboarding.products.note
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.os.Bundle
import android.view.View
import android.view.animation.OvershootInterpolator
import android.widget.ImageView
import androidx.annotation.LayoutRes
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.view.isVisible
import androidx.transition.TransitionManager
import coil.load
import com.tangem.blockchain.common.Blockchain
import com.tangem.tangem_sdk_new.extensions.fadeIn
import com.tangem.tangem_sdk_new.extensions.fadeOut
import com.tangem.tap.common.extensions.getDrawableCompat
import com.tangem.tap.common.extensions.stripZeroPlainString
import com.tangem.tap.common.redux.navigation.ShareElement
@ -33,23 +28,18 @@ import com.tangem.wallet.R
class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
private var previousStep: OnboardingNoteStep = OnboardingNoteStep.None
private val mainBinding by lazy { binding.vMain }
private lateinit var btnRefreshBalanceWidget: RefreshBalanceWidget
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
postponeEnterTransition()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
addBackPressHandler(this)
binding.onboardingTopContainer.imvFrontCard.transitionName = ShareElement.imvFrontCard
startPostponedEnterTransition()
mainBinding.onboardingTopContainer.imvFrontCard.transitionName = ShareElement.imvFrontCard
binding.toolbar.setTitle(R.string.onboarding_title)
btnRefreshBalanceWidget = RefreshBalanceWidget(binding.onboardingTopContainer.onboardingMainContainer)
btnRefreshBalanceWidget = RefreshBalanceWidget(mainBinding.onboardingTopContainer.onboardingWalletContainer)
store.dispatch(OnboardingNoteAction.LoadCardArtwork)
store.dispatch(OnboardingNoteAction.DetermineStepOfScreen)
@ -67,7 +57,7 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
override fun newState(state: OnboardingNoteState) {
if (activity == null || view == null) return
binding.onboardingTopContainer.imvFrontCard.load(state.cardArtworkUrl) {
mainBinding.onboardingTopContainer.imvFrontCard.load(state.cardArtworkUrl) {
placeholder(R.drawable.card_placeholder_black)
error(R.drawable.card_placeholder_black)
fallback(R.drawable.card_placeholder_black)
@ -90,7 +80,7 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
private fun setBalance(state: OnboardingNoteState) {
if (state.walletBalance.currency.blockchain == Blockchain.Unknown) return
with (binding.onboardingTopContainer.onboardingTvBalance) {
with(mainBinding.onboardingTopContainer.onboardingTvBalance) {
if (state.balanceCriticalError == null) {
val balanceValue = state.walletBalance.value.stripZeroPlainString()
tvBalanceValue.text = balanceValue
@ -102,7 +92,7 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
}
}
private fun setupNoneState() = with(binding.onboardingActionContainer) {
private fun setupNoneState() = with(mainBinding.onboardingActionContainer) {
btnMainAction.isVisible = false
btnAlternativeAction.isVisible = false
btnRefreshBalanceWidget.show(false)
@ -116,7 +106,7 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
}
private fun setupCreateWalletState(state: OnboardingNoteState) =
with(binding.onboardingActionContainer) {
with(mainBinding.onboardingActionContainer) {
btnMainAction.setText(R.string.onboarding_create_wallet_button_create_wallet)
btnMainAction.setOnClickListener { store.dispatch(OnboardingNoteAction.CreateWallet) }
btnAlternativeAction.setText(R.string.onboarding_button_what_does_it_mean)
@ -127,15 +117,15 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
tvHeader.setText(R.string.onboarding_create_wallet_header)
tvBody.setText(R.string.onboarding_create_wallet_body)
binding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_circle)
mainBinding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_circle),
)
updateConstraints(state.currentStep, R.layout.lp_onboarding_create_wallet)
btnAlternativeAction.isVisible = false // temporary
}
private fun setupTopUpWalletState(state: OnboardingNoteState) = with(binding.onboardingActionContainer) {
private fun setupTopUpWalletState(state: OnboardingNoteState) = with(mainBinding.onboardingActionContainer) {
if (state.isBuyAllowed) {
btnMainAction.setText(R.string.onboarding_top_up_button_but_crypto)
btnMainAction.setOnClickListener {
@ -163,7 +153,7 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
state.walletBalance.amountToCreateAccount?.let { amount ->
val tvBodyMessage = getString(
R.string.onboarding_top_up_body_no_account_error,
amount, state.walletBalance.currency.currencySymbol
amount, state.walletBalance.currency.currencySymbol,
)
tvBody.text = tvBodyMessage
}
@ -175,12 +165,13 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
store.dispatch(OnboardingNoteAction.Balance.Update)
}
}
binding.onboardingTopContainer.imvCardBackground
.setBackgroundDrawable(requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8))
val drawable = requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8)
mainBinding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(drawable)
updateConstraints(state.currentStep, R.layout.lp_onboarding_topup_wallet)
}
private fun setupDoneState(state: OnboardingNoteState) = with(binding.onboardingActionContainer) {
private fun setupDoneState(state: OnboardingNoteState) = with(mainBinding.onboardingActionContainer) {
btnMainAction.setText(R.string.onboarding_done_button_continue)
btnMainAction.setOnClickListener {
showConfetti(false)
@ -188,15 +179,15 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
}
btnAlternativeAction.isVisible = false
btnAlternativeAction.setText("")
btnAlternativeAction.text = ""
btnAlternativeAction.setOnClickListener { }
btnRefreshBalanceWidget.mainView.setOnClickListener(null)
tvHeader.setText(R.string.onboarding_done_header)
tvBody.setText(R.string.onboarding_done_body)
binding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8)
mainBinding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8),
)
updateConstraints(state.currentStep, R.layout.lp_onboarding_done_activation)
}
@ -204,22 +195,13 @@ class OnboardingNoteFragment : BaseOnboardingFragment<OnboardingNoteState>() {
private fun updateConstraints(currentStep: OnboardingNoteStep, @LayoutRes layoutId: Int) {
if (this.previousStep == currentStep) return
with(binding.onboardingTopContainer) {
with(mainBinding.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)
}
}
}
fun ImageView.swapToBitmapDrawable(bitmap: Bitmap?, duration: Long = 200) {
if (drawable is BitmapDrawable && (drawable as BitmapDrawable).bitmap == bitmap) return
fadeOut(duration) {
setImageBitmap(bitmap)
fadeIn(duration)
}
}

View file

@ -4,7 +4,12 @@ import com.tangem.common.CompletionResult
import com.tangem.common.extensions.guard
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.tap.DELAY_SDK_DIALOG_CLOSE
import com.tangem.tap.common.extensions.*
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.extensions.dispatchErrorNotification
import com.tangem.tap.common.extensions.dispatchOpenUrl
import com.tangem.tap.common.extensions.getAddressData
import com.tangem.tap.common.extensions.getToUpUrl
import com.tangem.tap.common.extensions.onCardScanned
import com.tangem.tap.common.postUi
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.common.redux.AppState
@ -115,11 +120,11 @@ private fun handleNoteAction(appState: () -> AppState?, action: Action, dispatch
val balanceIsLoading = noteState.walletBalance.copy(
currency = Currency.Blockchain(
walletManager.wallet.blockchain,
walletManager.wallet.publicKey.derivationPath?.rawPath
walletManager.wallet.publicKey.derivationPath?.rawPath,
),
state = ProgressState.Loading,
error = null,
criticalError = null
criticalError = null,
)
store.dispatch(OnboardingNoteAction.Balance.Set(balanceIsLoading))

View file

@ -24,17 +24,13 @@ import com.tangem.wallet.R
*/
class OnboardingOtherCardsFragment : BaseOnboardingFragment<OnboardingOtherCardsState>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
postponeEnterTransition()
}
private val mainBinding by lazy { binding.vMain }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
addBackPressHandler(this)
binding.onboardingTopContainer.imvFrontCard.transitionName = ShareElement.imvFrontCard
startPostponedEnterTransition()
mainBinding.onboardingTopContainer.imvFrontCard.transitionName = ShareElement.imvFrontCard
binding.toolbar.setTitle(R.string.onboarding_title)
store.dispatch(OnboardingOtherCardsAction.LoadCardArtwork)
@ -54,7 +50,7 @@ class OnboardingOtherCardsFragment : BaseOnboardingFragment<OnboardingOtherCards
if (activity == null || view == null) return
if (state.currentStep == OnboardingOtherCardsStep.None) return
binding.onboardingTopContainer.imvFrontCard.load(state.cardArtworkUrl) {
mainBinding.onboardingTopContainer.imvFrontCard.load(state.cardArtworkUrl) {
placeholder(R.drawable.card_placeholder_black)
error(R.drawable.card_placeholder_black)
fallback(R.drawable.card_placeholder_black)
@ -70,29 +66,25 @@ class OnboardingOtherCardsFragment : BaseOnboardingFragment<OnboardingOtherCards
showConfetti(state.showConfetti)
}
private fun setupCreateWalletState() = with(binding) {
private fun setupCreateWalletState() = with(mainBinding.onboardingActionContainer) {
btnMainAction.setText(R.string.onboarding_create_wallet_button_create_wallet)
btnMainAction.setOnClickListener { store.dispatch(OnboardingOtherCardsAction.CreateWallet) }
btnAlternativeAction.setText(R.string.onboarding_button_what_does_it_mean)
btnAlternativeAction.setOnClickListener { }
with(onboardingActionContainer) {
btnMainAction.setText(R.string.onboarding_create_wallet_button_create_wallet)
btnMainAction.setOnClickListener { store.dispatch(OnboardingOtherCardsAction.CreateWallet) }
btnAlternativeAction.setText(R.string.onboarding_button_what_does_it_mean)
btnAlternativeAction.setOnClickListener { }
tvHeader.setText(R.string.onboarding_create_wallet_header)
tvBody.setText(R.string.onboarding_create_wallet_body)
tvHeader.setText(R.string.onboarding_create_wallet_header)
tvBody.setText(R.string.onboarding_create_wallet_body)
btnAlternativeAction.isVisible = false // temporary
btnAlternativeAction.isVisible = false // temporary
}
binding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_circle)
mainBinding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_circle),
)
updateConstraints(R.layout.lp_onboarding_create_wallet)
}
private fun setupDoneState() = with(binding.onboardingActionContainer) {
private fun setupDoneState() = with(mainBinding.onboardingActionContainer) {
btnMainAction.setText(R.string.onboarding_done_button_continue)
btnMainAction.setOnClickListener {
showConfetti(false)
@ -106,20 +98,18 @@ class OnboardingOtherCardsFragment : BaseOnboardingFragment<OnboardingOtherCards
tvHeader.setText(R.string.onboarding_done_header)
tvBody.setText(R.string.onboarding_done_body)
binding.onboardingTopContainer.imvCardBackground?.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8)
mainBinding.onboardingTopContainer.imvCardBackground?.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8),
)
updateConstraints(R.layout.lp_onboarding_done)
}
private fun updateConstraints(@LayoutRes layoutId: Int) {
with(binding.onboardingTopContainer) {
val constraintSet = ConstraintSet()
constraintSet.clone(requireContext(), layoutId)
constraintSet.applyTo(onboardingMainContainer)
val transition = InternalNoteLayoutTransition()
transition.interpolator = OvershootInterpolator()
TransitionManager.beginDelayedTransition(onboardingMainContainer, transition)
}
private fun updateConstraints(@LayoutRes layoutId: Int) = with(mainBinding.onboardingTopContainer) {
val constraintSet = ConstraintSet()
constraintSet.clone(requireContext(), layoutId)
constraintSet.applyTo(onboardingWalletContainer)
val transition = InternalNoteLayoutTransition()
transition.interpolator = OvershootInterpolator()
TransitionManager.beginDelayedTransition(onboardingWalletContainer, transition)
}
}

View file

@ -79,17 +79,21 @@ private fun handleOtherCardsAction(action: Action, dispatch: DispatchFunction) {
val primaryBlockchain = updatedResponse.getBlockchain()
val blockchainNetworks = if (primaryBlockchain != Blockchain.Unknown) {
val primaryToken = updatedResponse.getPrimaryToken()
val blockchainNetwork = BlockchainNetwork(primaryBlockchain, updatedResponse.card).updateTokens(
listOfNotNull(primaryToken))
val blockchainNetwork =
BlockchainNetwork(primaryBlockchain, updatedResponse.card).updateTokens(
listOfNotNull(primaryToken),
)
listOf(blockchainNetwork)
} else {
listOf(
BlockchainNetwork(Blockchain.Bitcoin, updatedResponse.card),
BlockchainNetwork(Blockchain.Ethereum, updatedResponse.card)
BlockchainNetwork(Blockchain.Ethereum, updatedResponse.card),
)
}
store.dispatch(WalletAction.MultiWallet.SaveCurrencies(blockchainNetworks))
store.dispatch(
WalletAction.MultiWallet.SaveCurrencies(blockchainNetworks, updatedResponse.card),
)
delay(DELAY_SDK_DIALOG_CLOSE)
store.dispatch(OnboardingOtherCardsAction.SetStepOfScreen(OnboardingOtherCardsStep.Done))

View file

@ -40,6 +40,7 @@ import com.tangem.wallet.databinding.LayoutOnboardingContainerTopBinding
class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
private val mainBinding by lazy { binding.vMain }
private var previousStep = TwinCardsStep.None
private lateinit var twinsWidget: TwinsCardWidget
@ -47,7 +48,6 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
postponeEnterTransition()
store.dispatch(TwinCardsAction.Init)
}
@ -66,30 +66,30 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
super.onViewCreated(view, savedInstanceState)
addBackPressHandler(this)
reconfigureLayoutForTwins(binding.onboardingTopContainer)
reconfigureLayoutForTwins(mainBinding.onboardingTopContainer)
val typedValue = TypedValue()
resources.getValue(R.dimen.device_scale_factor_for_twins_welcome, typedValue, true)
val deviceScaleFactorForWelcomeState = typedValue.float
twinsWidget = TwinsCardWidget(
LeapfrogWidget(binding.onboardingTopContainer.cardsContainer),
deviceScaleFactorForWelcomeState
LeapfrogWidget(mainBinding.onboardingTopContainer.cardsContainer),
deviceScaleFactorForWelcomeState,
) {
285f * deviceScaleFactorForWelcomeState
}
btnRefreshBalanceWidget =
RefreshBalanceWidget(binding.onboardingTopContainer.onboardingMainContainer)
RefreshBalanceWidget(mainBinding.onboardingTopContainer.onboardingWalletContainer)
binding.toolbar.title = getText(R.string.twins_recreate_toolbar)
binding.onboardingTopContainer.imvTwinFrontCard.load(Artwork.TWIN_CARD_1) {
mainBinding.onboardingTopContainer.imvTwinFrontCard.load(Artwork.TWIN_CARD_1) {
placeholder(R.drawable.card_placeholder_black)
error(R.drawable.card_placeholder_black)
fallback(R.drawable.card_placeholder_black)
}
binding.onboardingTopContainer.imvTwinBackCard.load(Artwork.TWIN_CARD_2) {
mainBinding.onboardingTopContainer.imvTwinBackCard.load(Artwork.TWIN_CARD_2) {
placeholder(R.drawable.card_placeholder_white)
error(R.drawable.card_placeholder_white)
fallback(R.drawable.card_placeholder_white)
@ -143,7 +143,7 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
private fun setBalance(state: TwinCardsState) {
if (state.walletBalance.currency.blockchain == Blockchain.Unknown) return
with(binding.onboardingTopContainer.onboardingTvBalance) {
with(mainBinding.onboardingTopContainer.onboardingTvBalance) {
if (state.balanceCriticalError == null) {
val balanceValue = state.walletBalance.value.stripZeroPlainString()
tvBalanceValue.text = balanceValue
@ -167,26 +167,26 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
}
private fun setupWelcomeState(state: TwinCardsState, mainAction: VoidCallback) =
with(binding.onboardingActionContainer) {
twinsWidget.toWelcome(false) { startPostponedEnterTransition() }
with(mainBinding.onboardingActionContainer) {
twinsWidget.toWelcome(false)
binding.onboardingTopContainer.onboardingTwinsWelcomeBg.root.show()
mainBinding.onboardingTopContainer.onboardingTwinsWelcomeBg.root.show()
pbBinding.pbState.hide()
tvHeader.setText(R.string.twins_onboarding_subtitle)
tvBody.text = getString(
R.string.twins_onboarding_description_format,
state.cardNumber?.pairIndexNumber()
state.cardNumber?.pairIndexNumber(),
)
btnMainAction.setText(R.string.common_continue)
btnMainAction.setOnClickListener { mainAction() }
}
private fun setupWarningState(state: TwinCardsState) = with(binding.onboardingActionContainer) {
twinsWidget.toWelcome(false) { startPostponedEnterTransition() }
private fun setupWarningState(state: TwinCardsState) = with(mainBinding.onboardingActionContainer) {
twinsWidget.toWelcome(false)
binding.onboardingTopContainer.onboardingTwinsWelcomeBg.root.hide()
mainBinding.onboardingTopContainer.onboardingTwinsWelcomeBg.root.hide()
pbBinding.pbState.hide()
chbUnderstand.show()
@ -203,198 +203,185 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
}
}
private fun setupCreateFirstWalletState(state: TwinCardsState) =
with(binding.onboardingActionContainer) {
binding.onboardingTopContainer.onboardingTwinsWelcomeBg.root.hide()
binding.onboardingTopContainer.onboardingTwinsWelcomeBg.bgCircleLarge.hide()
binding.onboardingTopContainer.onboardingTwinsWelcomeBg.bgCircleMedium.hide()
binding.onboardingTopContainer.onboardingTwinsWelcomeBg.bgCircleMin.hide()
chbUnderstand.hide()
pbBinding.pbState.show()
private fun setupCreateFirstWalletState(state: TwinCardsState) = with(mainBinding.onboardingActionContainer) {
mainBinding.onboardingTopContainer.onboardingTwinsWelcomeBg.root.hide()
mainBinding.onboardingTopContainer.onboardingTwinsWelcomeBg.bgCircleLarge.hide()
mainBinding.onboardingTopContainer.onboardingTwinsWelcomeBg.bgCircleMedium.hide()
mainBinding.onboardingTopContainer.onboardingTwinsWelcomeBg.bgCircleMin.hide()
chbUnderstand.hide()
pbBinding.pbState.show()
binding.onboardingTopContainer.onboardingMainContainer.beginDelayedTransition()
mainBinding.onboardingTopContainer.onboardingWalletContainer.beginDelayedTransition()
when (previousStep) {
TwinCardsStep.None -> {
twinsWidget.toLeapfrog(false) {
when (state.cardNumber) {
TwinCardNumber.First -> startPostponedEnterTransition()
TwinCardNumber.Second -> {
switchToCard(
state.cardNumber,
false
) { startPostponedEnterTransition() }
}
}
}
}
TwinCardsStep.Welcome, TwinCardsStep.Warning -> {
twinsWidget.toLeapfrog(onEnd = { switchToCard(state.cardNumber) })
}
}
val twinIndexNumber = state.cardNumber?.indexNumber()
tvHeader.text = getString(R.string.twins_recreate_title_format, twinIndexNumber)
tvBody.setText(R.string.onboarding_twins_interrupt_warning)
btnMainAction.text = getString(R.string.twins_recreate_button_format, twinIndexNumber)
btnMainAction.setOnClickListener {
store.dispatch(
TwinCardsAction.Wallet.LaunchFirstStep(
Message(getString(R.string.twins_recreate_title_format, twinIndexNumber)),
AndroidAssetReader(requireContext()),
)
)
}
btnAlternativeAction.isVisible = false
btnAlternativeAction.isClickable = false
}
private fun setupCreateSecondWalletState(state: TwinCardsState) =
with(binding.onboardingActionContainer) {
switchToCard(state.cardNumber?.pairNumber())
val twinPairIndexNumber = state.cardNumber?.pairIndexNumber()
tvHeader.text = getString(R.string.twins_recreate_title_format, twinPairIndexNumber)
tvBody.setText(R.string.onboarding_twins_interrupt_warning)
btnMainAction.text =
getString(R.string.twins_recreate_button_format, twinPairIndexNumber)
btnMainAction.setOnClickListener {
store.dispatch(
TwinCardsAction.Wallet.LaunchSecondStep(
Message(
getString(
R.string.twins_recreate_title_format,
twinPairIndexNumber
)
),
Message(getString(R.string.twins_recreate_title_preparing)),
Message(getString(R.string.twins_recreate_title_creating_wallet)),
)
)
}
}
private fun setupCreateThirdWalletState(state: TwinCardsState) =
with(binding.onboardingActionContainer) {
switchToCard(state.cardNumber)
val twinIndexNumber = state.cardNumber?.indexNumber()
tvHeader.text = getString(R.string.twins_recreate_title_format, twinIndexNumber)
tvBody.setText(R.string.onboarding_twins_interrupt_warning)
btnMainAction.text = getString(R.string.twins_recreate_button_format, twinIndexNumber)
btnMainAction.setOnClickListener {
store.dispatch(
TwinCardsAction.Wallet.LaunchThirdStep(
Message(getString(R.string.twins_recreate_title_format, twinIndexNumber))
)
)
}
}
private fun setupTopUpWalletState(state: TwinCardsState) =
with(binding.onboardingActionContainer) {
when (previousStep) {
TwinCardsStep.None -> {
when (previousStep) {
TwinCardsStep.None -> {
twinsWidget.toLeapfrog(false) {
when (state.cardNumber) {
TwinCardNumber.First -> {
twinsWidget.leapfrogWidget.unfold(false) {
twinsWidget.toActivate(false) {
startPostponedEnterTransition()
}
}
}
TwinCardNumber.First -> {}
TwinCardNumber.Second -> {
switchToCard(state.cardNumber, false) {
twinsWidget.toActivate(false)
startPostponedEnterTransition()
}
switchToCard(
state.cardNumber,
false,
)
}
}
}
TwinCardsStep.CreateThirdWallet -> {
twinsWidget.toActivate()
}
}
if (state.isBuyAllowed) {
btnMainAction.setText(R.string.onboarding_top_up_button_but_crypto)
btnMainAction.setOnClickListener {
store.dispatch(TwinCardsAction.TopUp)
}
btnAlternativeAction.isVisible = true
btnAlternativeAction.setText(R.string.onboarding_top_up_button_show_wallet_address)
btnAlternativeAction.setOnClickListener {
store.dispatch(TwinCardsAction.ShowAddressInfoDialog)
}
} else {
btnMainAction.setText(R.string.onboarding_button_receive_crypto)
btnMainAction.setOnClickListener {
store.dispatch(TwinCardsAction.ShowAddressInfoDialog)
}
btnAlternativeAction.isVisible = false
TwinCardsStep.Welcome, TwinCardsStep.Warning -> {
twinsWidget.toLeapfrog(onEnd = { switchToCard(state.cardNumber) })
}
tvHeader.setText(R.string.onboarding_top_up_header)
tvBody.setText(R.string.onboarding_top_up_body)
btnRefreshBalanceWidget.changeState(state.walletBalance.state)
if (btnRefreshBalanceWidget.isShowing != true) {
btnRefreshBalanceWidget.mainView.setOnClickListener {
store.dispatch(TwinCardsAction.Balance.Update)
}
}
binding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8)
)
updateConstraints(state.currentStep, R.layout.lp_onboarding_topup_wallet_twins)
}
private fun setupDoneState(state: TwinCardsState) =
with(binding.onboardingActionContainer) {
btnMainAction.setText(R.string.onboarding_done_button_continue)
val twinIndexNumber = state.cardNumber?.indexNumber()
tvHeader.text = getString(R.string.twins_recreate_title_format, twinIndexNumber)
tvBody.setText(R.string.onboarding_twins_interrupt_warning)
btnMainAction.text = getString(R.string.twins_recreate_button_format, twinIndexNumber)
btnMainAction.setOnClickListener {
store.dispatch(
TwinCardsAction.Wallet.LaunchFirstStep(
Message(getString(R.string.twins_recreate_title_format, twinIndexNumber)),
AndroidAssetReader(requireContext()),
),
)
}
btnAlternativeAction.isVisible = false
btnAlternativeAction.isClickable = false
}
private fun setupCreateSecondWalletState(state: TwinCardsState) = with(mainBinding.onboardingActionContainer) {
switchToCard(state.cardNumber?.pairNumber())
val twinPairIndexNumber = state.cardNumber?.pairIndexNumber()
tvHeader.text = getString(R.string.twins_recreate_title_format, twinPairIndexNumber)
tvBody.setText(R.string.onboarding_twins_interrupt_warning)
btnMainAction.text =
getString(R.string.twins_recreate_button_format, twinPairIndexNumber)
btnMainAction.setOnClickListener {
store.dispatch(
TwinCardsAction.Wallet.LaunchSecondStep(
Message(
getString(
R.string.twins_recreate_title_format,
twinPairIndexNumber,
),
),
Message(getString(R.string.twins_recreate_title_preparing)),
Message(getString(R.string.twins_recreate_title_creating_wallet)),
),
)
}
}
private fun setupCreateThirdWalletState(state: TwinCardsState) = with(mainBinding.onboardingActionContainer) {
switchToCard(state.cardNumber)
val twinIndexNumber = state.cardNumber?.indexNumber()
tvHeader.text = getString(R.string.twins_recreate_title_format, twinIndexNumber)
tvBody.setText(R.string.onboarding_twins_interrupt_warning)
btnMainAction.text = getString(R.string.twins_recreate_button_format, twinIndexNumber)
btnMainAction.setOnClickListener {
store.dispatch(
TwinCardsAction.Wallet.LaunchThirdStep(
Message(getString(R.string.twins_recreate_title_format, twinIndexNumber)),
),
)
}
}
private fun setupTopUpWalletState(state: TwinCardsState) = with(mainBinding.onboardingActionContainer) {
when (previousStep) {
TwinCardsStep.None -> {
when (state.cardNumber) {
TwinCardNumber.First -> {
twinsWidget.leapfrogWidget.unfold(false) {
twinsWidget.toActivate(false)
}
}
TwinCardNumber.Second -> {
switchToCard(state.cardNumber, false) {
twinsWidget.toActivate(false)
}
}
}
}
TwinCardsStep.CreateThirdWallet -> {
twinsWidget.toActivate()
}
}
if (state.isBuyAllowed) {
btnMainAction.setText(R.string.onboarding_top_up_button_but_crypto)
btnMainAction.setOnClickListener {
store.dispatch(TwinCardsAction.Confetti.Hide)
store.dispatch(TwinCardsAction.Done)
store.dispatch(TwinCardsAction.TopUp)
}
btnAlternativeAction.isVisible = true
btnAlternativeAction.setText(R.string.onboarding_top_up_button_show_wallet_address)
btnAlternativeAction.setOnClickListener {
store.dispatch(TwinCardsAction.ShowAddressInfoDialog)
}
} else {
btnMainAction.setText(R.string.onboarding_button_receive_crypto)
btnMainAction.setOnClickListener {
store.dispatch(TwinCardsAction.ShowAddressInfoDialog)
}
btnAlternativeAction.isVisible = false
btnAlternativeAction.isClickable = false
tvHeader.setText(R.string.onboarding_done_header)
tvBody.setText(R.string.onboarding_done_body)
val layout = when (state.mode) {
CreateTwinWalletMode.CreateWallet -> R.layout.lp_onboarding_done_activation_twins
CreateTwinWalletMode.RecreateWallet -> R.layout.lp_onboarding_done
}
updateConstraints(state.currentStep, layout)
}
tvHeader.setText(R.string.onboarding_top_up_header)
tvBody.setText(R.string.onboarding_top_up_body)
btnRefreshBalanceWidget.changeState(state.walletBalance.state)
if (btnRefreshBalanceWidget.isShowing != true) {
btnRefreshBalanceWidget.mainView.setOnClickListener {
store.dispatch(TwinCardsAction.Balance.Update)
}
}
mainBinding.onboardingTopContainer.imvCardBackground.setBackgroundDrawable(
requireContext().getDrawableCompat(R.drawable.shape_rectangle_rounded_8),
)
updateConstraints(state.currentStep, R.layout.lp_onboarding_topup_wallet_twins)
}
private fun setupDoneState(state: TwinCardsState) = with(mainBinding.onboardingActionContainer) {
btnMainAction.setText(R.string.onboarding_done_button_continue)
btnMainAction.setOnClickListener {
store.dispatch(TwinCardsAction.Confetti.Hide)
store.dispatch(TwinCardsAction.Done)
}
btnAlternativeAction.isVisible = false
btnAlternativeAction.isClickable = false
tvHeader.setText(R.string.onboarding_done_header)
tvBody.setText(R.string.onboarding_done_body)
val layout = when (state.mode) {
CreateTwinWalletMode.CreateWallet -> R.layout.lp_onboarding_done_activation_twins
CreateTwinWalletMode.RecreateWallet -> R.layout.lp_onboarding_done
}
updateConstraints(state.currentStep, layout)
}
private fun updateConstraints(currentStep: TwinCardsStep, @LayoutRes layoutId: Int) {
if (this.previousStep == currentStep) return
with(binding.onboardingTopContainer) {
with(mainBinding.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)
}
}
private fun switchToCard(
cardNumber: TwinCardNumber?,
animate: Boolean = true,
onEnd: VoidCallback = {}
) {
private fun switchToCard(cardNumber: TwinCardNumber?, animate: Boolean = true, onEnd: VoidCallback = {}) {
val position = cardNumber?.number ?: return
if (twinsWidget.leapfrogWidget.getViewPositionByIndex(1) != position - 1) {
@ -405,12 +392,14 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
}
override fun handleOnBackPressed() {
store.dispatch(TwinCardsAction.Wallet.HandleOnBackPressed { should, popAction ->
store.dispatch(TwinCardsAction.Confetti.Hide)
showConfetti(false)
if (should) switchToCard(TwinCardNumber.First, true, popAction)
else popAction()
})
store.dispatch(
TwinCardsAction.Wallet.HandleOnBackPressed { should, popAction ->
store.dispatch(TwinCardsAction.Confetti.Hide)
showConfetti(false)
if (should) switchToCard(TwinCardNumber.First, true, popAction)
else popAction()
},
)
}
}

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()
@ -28,6 +30,11 @@ sealed class BackupAction : Action {
object StartAddingPrimaryCard : BackupAction()
object ScanPrimaryCard : BackupAction()
/**
* Check for unfinished backup of standard Wallet cards.
* For SaltPay cards unfinished backup resumed after scanning the card on HomeScreen through Onboarding.Start.
* See more Onboarding.Start, CheckForUnfinishedBackup, StartForUnfinishedBackup
*/
object CheckForUnfinishedBackup : BackupAction()
object StartAddingBackupCards : BackupAction()
@ -53,15 +60,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

@ -3,10 +3,13 @@ package com.tangem.tap.features.onboarding.products.wallet.redux
import com.tangem.blockchain.common.Blockchain
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.dispatchDialogShow
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,19 @@ 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.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.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 +43,59 @@ 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 {
when {
card == null -> {
OnboardingWalletAction.ProceedBackup
// it's possible when found unfinished backup for standard Wallet cards
store.dispatch(OnboardingWalletAction.ResumeBackup)
}
card.hasWallets() && card.backupStatus == Card.BackupStatus.NoBackup -> {
store.dispatch(OnboardingWalletAction.ResumeBackup)
}
card.hasWallets() && card.backupStatus?.isActive == true -> {
when {
// check for unfinished backup for saltPay cards. See more
scanResponse.isSaltPay() && backupService.hasIncompletedBackup -> {
store.dispatch(OnboardingWalletAction.ResumeBackup)
}
scanResponse.isSaltPay() -> {
store.dispatch(OnboardingWalletAction.GetToSaltPayStep)
store.dispatch(BackupAction.FinishBackup)
}
else -> 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 +116,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 +145,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 +156,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,16 +177,9 @@ private fun handleWalletAction(action: Action) {
newAction?.let { store.dispatch(it) }
}
OnboardingWalletAction.OnBackPressed -> {
when (walletState.backupState.backupStep) {
BackupStep.InitBackup, BackupStep.Finished -> store.dispatch(NavigationAction.PopBackTo())
BackupStep.ScanOriginCard, BackupStep.AddBackupCards, BackupStep.EnterAccessCode,
BackupStep.ReenterAccessCode, BackupStep.SetAccessCode, BackupStep.WritePrimaryCard,
-> {
store.dispatch(BackupAction.DiscardBackup)
store.dispatch(NavigationAction.PopBackTo())
}
is BackupStep.WriteBackupCard ->
store.dispatch(GlobalAction.ShowDialog(BackupDialog.BackupInProgress))
when {
onboardingWalletState.isSaltPay -> handleOnBackPressedSaltPay(onboardingWalletState)
else -> handleOnBackPressed(onboardingWalletState)
}
}
}
@ -159,7 +192,7 @@ private fun updateScanResponseAfterBackup(
val cardsCount = backupState.backupCardsNumber
scanResponse.card.copy(
backupStatus = Card.BackupStatus.Active(cardCount = cardsCount),
isAccessCodeSet = true
isAccessCodeSet = true,
)
} else {
scanResponse.card
@ -180,13 +213,11 @@ class BackupMiddleware {
private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) {
if (DemoHelper.tryHandle(appState, action)) return
val globalState = appState()?.globalState ?: return
val onboardingWalletState = appState()?.onboardingWalletState ?: return
val backupState = store.state.onboardingWalletState.backupState
val globalState = store.state.globalState
val onboardingManager = globalState.onboardingState.onboardingManager
val scanResponse = onboardingManager?.scanResponse
val backupState = onboardingWalletState.backupState
val scanResponse = globalState.onboardingState.onboardingManager?.scanResponse
val card = scanResponse?.card
when (action) {
@ -212,32 +243,33 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
}
}
is BackupAction.AddBackupCard -> {
onboardingWalletState.backupCardIdFilter?.let {
backupService.skipCompatibilityChecks = true
tangemSdk.config.filter.cardIdFilter = it
}
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 +279,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 +301,13 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
when (result) {
is CompletionResult.Success -> {
if (backupService.currentState == BackupService.State.Finished) {
initSaltPayOnBackupFinishedIfNeeded(scanResponse, onboardingWalletState)
store.dispatchOnMain(BackupAction.FinishBackup)
} else {
store.dispatchOnMain(
BackupAction.PrepareToWriteBackupCard(action.cardNumber + 1)
)
store.dispatchOnMain(BackupAction.PrepareToWriteBackupCard(action.cardNumber + 1))
}
}
is CompletionResult.Failure -> {
}
}
}
@ -304,17 +329,68 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
backupService.discardSavedBackup()
}
is BackupAction.CheckForUnfinishedBackup -> {
if (backupService.hasIncompletedBackup) {
if (backupService.hasIncompletedBackup && !backupService.primaryCardIsSaltPay()) {
store.dispatch(GlobalAction.ShowDialog(BackupDialog.UnfinishedBackupFound))
}
}
is BackupAction.ResumeBackup -> {
store.dispatch(GlobalAction.Onboarding.Start(null, true))
store.dispatch(OnboardingWalletAction.ProceedBackup)
is BackupAction.ResumeFoundUnfinishedBackup -> {
store.dispatch(GlobalAction.Onboarding.StartForUnfinishedBackup)
store.dispatch(NavigationAction.NavigateTo(AppScreen.OnboardingWallet))
}
is BackupAction.DismissBackup -> {
if (onboardingWalletState.isSaltPay) throw UnsupportedOperationException()
store.dispatch(BackupAction.FinishBackup)
}
}
}
private fun BackupService.primaryCardIsSaltPay(): Boolean {
return primaryCardId?.slice(0..3)?.let {
SaltPayWorkaround.isVisaBatchId(it)
} ?: false
}
/**
* 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)
}
}
private fun handleOnBackPressedSaltPay(state: OnboardingWalletState) {
when (state.backupState.backupStep) {
BackupStep.Finished -> {
store.dispatchDialogShow(
SaltPayDialog.Activation.TryToInterrupt(
onOk = { store.dispatch(NavigationAction.PopBackTo()) },
onCancel = { /* do nothing */ },
),
)
}
else -> handleOnBackPressed(state)
}
}
private fun handleOnBackPressed(state: OnboardingWalletState) {
when (state.backupState.backupStep) {
BackupStep.InitBackup, BackupStep.Finished -> store.dispatch(NavigationAction.PopBackTo())
BackupStep.ScanOriginCard, BackupStep.AddBackupCards, BackupStep.EnterAccessCode,
BackupStep.ReenterAccessCode, BackupStep.SetAccessCode, BackupStep.WritePrimaryCard,
-> {
store.dispatch(BackupAction.DiscardBackup)
store.dispatch(NavigationAction.PopBackTo())
}
is BackupStep.WriteBackupCard ->
store.dispatch(GlobalAction.ShowDialog(BackupDialog.BackupInProgress))
}
}

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,47 @@ private fun internalReduce(action: Action, appState: AppState): OnboardingWallet
}
}
private class ReducerForGlobalAction {
companion object {
fun reduce(action: GlobalAction.Onboarding, state: OnboardingWalletState): OnboardingWalletState {
return 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 = 2,
canSkipBackup = false,
),
isSaltPay = false,
)
}
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 +85,6 @@ class BackupReducer {
BackupAction.AddBackupCard.Success -> {
state.copy(backupCardsNumber = state.backupCardsNumber + 1)
}
BackupAction.FinishAddingBackupCards -> {
state
}
BackupAction.ShowAccessCodeInfoScreen -> {
state.copy(backupStep = BackupStep.SetAccessCode)
}
@ -62,13 +94,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 +106,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 +118,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

@ -1,7 +1,11 @@
package com.tangem.tap.features.onboarding.products.wallet.redux
import android.graphics.Bitmap
import com.tangem.common.CardFilter
import com.tangem.domain.common.SaltPayWorkaround
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.SaltPayActivationStep
import org.rekotlin.StateType
/**
@ -10,42 +14,69 @@ 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 {
val backupCardIdFilter: CardFilter.Companion.CardIdFilter?
get() = when {
isSaltPay -> CardFilter.Companion.CardIdFilter.Allow(
items = SaltPayWorkaround.walletCardIds.toSet(),
ranges = SaltPayWorkaround.walletCardIdRanges,
)
else -> null
}
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
BackupStep.ScanOriginCard -> 3
BackupStep.AddBackupCards -> 4
BackupStep.EnterAccessCode -> 4
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) {
SaltPayActivationStep.None, SaltPayActivationStep.NoGas -> 0
SaltPayActivationStep.NeedPin -> 7
SaltPayActivationStep.CardRegistration -> 8
SaltPayActivationStep.KycIntro, SaltPayActivationStep.KycStart,
SaltPayActivationStep.KycWaiting, SaltPayActivationStep.KycReject,
-> 9
SaltPayActivationStep.Claim -> 10
SaltPayActivationStep.ClaimInProgress -> 11
SaltPayActivationStep.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 +103,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,182 @@
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.TransactionSigner
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 addressTreasureSafe = "0x8e9260a049d3Aa9ac60D0d4F27017320E0e2396B"
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)
}
suspend fun getAllowance(): Result<Amount> {
return walletManager.getAllowance(addressTreasureSafe, token)
}
suspend fun transferFrom(amountToClaim: BigDecimal, signer: TransactionSigner): Result<Unit> {
val amount = Amount(token, amountToClaim)
val feeAmount = walletManager.getFeeToTransferFrom(amount, addressTreasureSafe)
.extractFeeAmount().successOr { return it }
val transactionData = walletManager.createTransferFromTransaction(amount, feeAmount, addressTreasureSafe)
walletManager.transferFrom(transactionData, signer).successOr { return Result.Failure(it.error) }
return Result.Success(Unit)
}
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,198 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay
import android.net.Uri
import com.tangem.blockchain.blockchains.ethereum.SignedEthereumTransaction
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.extensions.successOr
import com.tangem.common.extensions.guard
import com.tangem.common.extensions.isZero
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.common.extensions.safeUpdate
import com.tangem.tap.domain.getFirstToken
import com.tangem.tap.domain.tokens.UserWalletId
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayActivationError
import com.tangem.tap.persistence.SaltPayActivationStorage
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
class SaltPayActivationManager(
val cardId: String,
val cardPublicKey: ByteArray,
val walletPublicKey: ByteArray,
private val kycProvider: KYCProvider,
private val paymentologyService: PaymentologyApiService,
private val gnosisRegistrator: GnosisRegistrator,
private val registrationStorage: SaltPayActivationStorage,
) {
val kycUrlProvider = KYCUrlProvider(walletPublicKey, kycProvider)
private val approvalValue: BigDecimal = BigDecimal(2).pow(256).minus(BigDecimal.ONE)
.movePointLeft(gnosisRegistrator.walletManager.wallet.blockchain.decimals())
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(SaltPayActivationError.NoGas)
}
is com.tangem.blockchain.extensions.Result.Failure -> Result.Failure(SaltPayActivationError.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 SaltPayActivationError.EmptyResponse(registrationResponse.error)
}
Result.Success(registrationResponse.results[0])
} catch (ex: SaltPayActivationError) {
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
}
suspend fun getAmountToClaim(): Result<Amount> {
val amount = gnosisRegistrator.getAllowance().successOr {
return Result.Failure(SaltPayActivationError.FailedToGetFundsToClaim)
}
return if (amount.value == null || amount.value?.isZero() == true) {
Result.Failure(SaltPayActivationError.NoFundsToClaim)
} else {
Result.Success(amount)
}
}
suspend fun claim(amountToClaim: BigDecimal, signer: TransactionSigner): Result<Unit> {
val result = gnosisRegistrator.transferFrom(amountToClaim, signer).successOr {
return Result.Failure(SaltPayActivationError.ClaimTransactionFailed)
}
return Result.Success(Unit)
}
suspend fun getTokenAmount(): Result<BigDecimal> {
val wallet = gnosisRegistrator.walletManager.safeUpdate().successOr { return it }
val token = wallet.getFirstToken().guard {
throw UnsupportedOperationException()
}
val amountValue = wallet.amounts[AmountType.Token(token)]?.value.guard {
throw UnsupportedOperationException()
}
return Result.Success(amountValue)
}
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(): SaltPayActivationManager = SaltPayActivationManager(
kycProvider = SaltPayConfig.stub().kycProvider,
paymentologyService = PaymentologyApiService.stub(),
gnosisRegistrator = GnosisRegistrator.stub(),
registrationStorage = SaltPayActivationStorage.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,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,43 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay
import com.tangem.common.core.TangemSdkError
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.SaltPayDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayActivationError
import com.tangem.tap.store
/**
[REDACTED_AUTHOR]
*/
class SaltPayExceptionHandler {
companion object {
fun handle(throwable: Throwable) {
when (throwable) {
is SaltPayActivationError -> {
val dialog = when (throwable) {
is SaltPayActivationError.NoGas -> SaltPayDialog.Activation.NoGas
else -> SaltPayDialog.Activation.OnError(throwable)
}
store.dispatchDialogShow(dialog)
}
is TangemSdkError -> {
when (throwable) {
is TangemSdkError.NetworkError -> {
val message = (throwable).customMessage
store.dispatchDialogShow(AppDialog.SimpleOkErrorDialog(message))
}
else -> {
// do nothing
}
}
}
else -> {
val message = throwable.message ?: "SaltPay unknown error"
store.dispatchDialogShow(AppDialog.SimpleOkErrorDialog(message))
}
}
}
}
}

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.Activation.OnError): 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,16 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog
import com.tangem.common.extensions.VoidCallback
import com.tangem.tap.common.redux.StateDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayActivationError
/**
[REDACTED_AUTHOR]
*/
sealed class SaltPayDialog : StateDialog {
sealed class Activation : SaltPayDialog() {
object NoGas : SaltPayDialog()
data class OnError(val error: SaltPayActivationError) : SaltPayDialog()
data class TryToInterrupt(val onOk: VoidCallback, val onCancel: VoidCallback) : SaltPayDialog()
}
}

View file

@ -0,0 +1,26 @@
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.extensions.dispatchDialogHide
import com.tangem.tap.store
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
class TryToInterruptRegistrationDialog {
companion object {
fun create(context: Context, dialog: SaltPayDialog.Activation.TryToInterrupt): Dialog {
return AlertDialog.Builder(context).apply {
setTitle(context.getString(R.string.onboarding_exit_alert_title))
setMessage(context.getString(R.string.onboarding_exit_alert_message))
setPositiveButton(R.string.common_ok) { _, _ -> dialog.onOk() }
setNegativeButton(R.string.common_cancel) { _, _ -> dialog.onCancel() }
setOnDismissListener { store.dispatchDialogHide() }
setCancelable(false)
}.create()
}
}
}

View file

@ -0,0 +1,56 @@
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 SaltPayActivationError(
subCode: Int,
message: String? = null,
override val data: Any? = null,
) : SaltPayModuleError(
subCode = ERROR_CODE_REGISTRATION + subCode,
message = message ?: EMPTY_MESSAGE,
) {
object Unknown : SaltPayActivationError(4)
object Empty : SaltPayActivationError(4)
// gnosis
object FailedToMakeTxData : SaltPayActivationError(1)
object FailedToSendTx : SaltPayActivationError(2)
object NeedPin : SaltPayActivationError(3)
object NoGas : SaltPayActivationError(5) //
class EmptyResponse(message: String?) : SaltPayActivationError(6, message)
class CardNotFound(message: String?) : SaltPayActivationError(8, message)
class CardDisabled(message: String?) : SaltPayActivationError(9, message)
class CardNotPassed(message: String?) : SaltPayActivationError(10, message)
object EmptyDynamicAttestResponse : SaltPayActivationError(11)
object EmptyBackupCardScanned : SaltPayActivationError(12)
object WeakPin : SaltPayActivationError(13)
object FailedToGetFundsToClaim : SaltPayActivationError(14)
object NoFundsToClaim : SaltPayActivationError(15)
object ClaimTransactionFailed : SaltPayActivationError(16)
object NotClaimed : SaltPayActivationError(17)
companion object {
const val EMPTY_MESSAGE = ""
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.tap.features.onboarding.products.wallet.saltPay.redux
import com.tangem.blockchain.common.Amount
import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayActivationManager
import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayConfig
import org.rekotlin.Action
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
sealed class OnboardingSaltPayAction : Action {
sealed class Init : OnboardingSaltPayAction() {
data class SetDependencies(
val registrationManager: SaltPayActivationManager,
val saltPayConfig: SaltPayConfig,
) : Init()
}
data class SetInProgress(val isInProgress: Boolean) : OnboardingSaltPayAction()
data class SetClaimRefreshInProgress(val isInProgress: Boolean) : OnboardingSaltPayAction()
data class SetAccessCode(val accessCode: String?) : OnboardingSaltPayAction()
object Update : OnboardingSaltPayAction()
object RegisterCard : OnboardingSaltPayAction()
object OpenUtorgKYC : OnboardingSaltPayAction()
object UtorgKYCRedirectSuccess : OnboardingSaltPayAction()
object RegisterKYC : OnboardingSaltPayAction()
object Claim : OnboardingSaltPayAction()
object RefreshClaim : OnboardingSaltPayAction()
data class TrySetPin(val pin: String) : OnboardingSaltPayAction()
data class SetPin(val pin: String) : OnboardingSaltPayAction()
data class SetStep(val newStep: SaltPayActivationStep? = null) : OnboardingSaltPayAction()
data class SetAmountToClaim(val amount: Amount?) : OnboardingSaltPayAction()
data class SetTokenBalance(val balanceValue: BigDecimal) : OnboardingSaltPayAction()
}

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