Updated on 2026-08-14

This commit is contained in:
Tangem 2023-02-28 11:50:28 +03:00
commit 2d8646ac6b
138 changed files with 3932 additions and 619 deletions

View file

@ -196,6 +196,7 @@ dependencies {
}
implementation(Library.accompanistAppCompatTheme)
implementation(Library.accompanistSystemUiController)
implementation(Library.accompanistWebView)
implementation(Library.xmlShimmer)
implementation(Library.viewBindingDelegate)
implementation(Library.armadillo)

View file

@ -139,9 +139,12 @@
<activity android:name="com.tangem.tap.common.qrCodeScan.ScanQrCodeActivity" />
<activity android:name="zendesk.messaging.MessagingActivity"
<activity
android:name="zendesk.messaging.MessagingActivity"
android:theme="@style/ZendeskTheme" />
<activity android:name="com.tangem.tap.features.sprinklr.ui.SprinklrActivity" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

@ -1 +1 @@
Subproject commit 93150ef69daf244a92266d7cdfbc9294b745b4e2
Subproject commit f5f6ac954894ef66331db8fe383b9773feb69ef4

View file

@ -12,9 +12,9 @@ import com.tangem.blockchain.common.WalletManagerFactory
import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder
import com.tangem.core.analytics.Analytics
import com.tangem.datasource.api.common.MoshiConverter
import com.tangem.datasource.config.models.Config
import com.tangem.datasource.config.ConfigManager
import com.tangem.datasource.config.FeaturesLocalLoader
import com.tangem.datasource.config.models.Config
import com.tangem.datasource.utils.AndroidAssetReader
import com.tangem.datasource.utils.AssetReader
import com.tangem.domain.DomainLayer
@ -27,6 +27,7 @@ import com.tangem.tap.common.analytics.filters.BasicTopUpFilter
import com.tangem.tap.common.analytics.handlers.amplitude.AmplitudeAnalyticsHandler
import com.tangem.tap.common.analytics.handlers.appsFlyer.AppsFlyerAnalyticsHandler
import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandler
import com.tangem.tap.common.chat.ChatManager
import com.tangem.tap.common.feedback.AdditionalFeedbackInfo
import com.tangem.tap.common.feedback.FeedbackManager
import com.tangem.tap.common.images.createCoilImageLoader
@ -52,11 +53,10 @@ import com.tangem.tap.network.NetworkConnectivity
import com.tangem.tap.persistence.PreferencesStorage
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.wallet.BuildConfig
import com.zendesk.logger.Logger
import dagger.hilt.android.HiltAndroidApp
import okhttp3.logging.HttpLoggingInterceptor
import org.rekotlin.Store
import timber.log.Timber
import zendesk.chat.Chat
import javax.inject.Inject
lateinit var store: Store<AppState>
@ -146,7 +146,11 @@ class TapApplication : Application(), ImageLoaderFactory {
initConfigManager(configLoader, ::initWithConfigDependency)
initWarningMessagesManager()
BlockchainSdkRetrofitBuilder.enableNetworkLogging = LogConfig.network.blockchainSdkNetwork
if (LogConfig.network.blockchainSdkNetwork) {
BlockchainSdkRetrofitBuilder.interceptors = listOf(
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY },
)
}
userTokensRepository = UserTokensRepository.init(
context = this,
@ -174,7 +178,7 @@ class TapApplication : Application(), ImageLoaderFactory {
private fun initWithConfigDependency(config: Config) {
shopService = TangemShopService(this, config.shopify!!)
initAnalytics(this, config)
initFeedbackManager(this, preferencesStorage)
initFeedbackManager(this, preferencesStorage, foregroundActivityObserver, store)
}
private fun initAnalytics(application: Application, config: Config) {
@ -196,7 +200,12 @@ class TapApplication : Application(), ImageLoaderFactory {
factory.build(Analytics, buildData)
}
private fun initFeedbackManager(context: Context, preferencesStorage: PreferencesStorage) {
private fun initFeedbackManager(
context: Context,
preferencesStorage: PreferencesStorage,
foregroundActivityObserver: ForegroundActivityObserver,
store: Store<AppState>,
) {
fun initAdditionalFeedbackInfo(context: Context): AdditionalFeedbackInfo = AdditionalFeedbackInfo().apply {
appVersion = try {
val pInfo = context.packageManager.getPackageInfo(context.packageName, 0)
@ -229,12 +238,8 @@ class TapApplication : Application(), ImageLoaderFactory {
val feedbackManager = FeedbackManager(
infoHolder = additionalFeedbackInfo,
logCollector = tangemLogCollector,
preferencesStorage = preferencesStorage,
chatManager = ChatManager(preferencesStorage, foregroundActivityObserver, store),
)
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,34 @@
package com.tangem.tap.common
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
/**
[REDACTED_AUTHOR]
*/
open class ShimmerRecyclerAdapter(
private val viewHolderViewFactory: (ViewGroup) -> ViewGroup,
) : ListAdapter<ShimmerData, ShimmerVH>(DiffUtilCallback) {
override fun getItemId(position: Int): Long {
return if (currentList.isEmpty()) 0 else currentList[position].hashCode().toLong()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ShimmerVH {
return ShimmerVH(viewHolderViewFactory.invoke(parent))
}
override fun onBindViewHolder(holder: ShimmerVH, position: Int) {}
object DiffUtilCallback : DiffUtil.ItemCallback<ShimmerData>() {
override fun areContentsTheSame(oldItem: ShimmerData, newItem: ShimmerData) = oldItem == newItem
override fun areItemsTheSame(oldItem: ShimmerData, newItem: ShimmerData) = oldItem == newItem
}
}
class ShimmerVH(viewGroup: ViewGroup) : RecyclerView.ViewHolder(viewGroup)
@Suppress("UnusedPrivateMember")
data class ShimmerData(private val any: String = "")

View file

@ -114,19 +114,53 @@ data class BasicEventsSourceData(
val paramCardBalanceState: AnalyticsParam.CardBalanceState by lazy { calculateAmount().toCardBalanceState() }
private fun calculateAmount(): BigDecimal {
return biometricsWalletDataModels?.calculateTotalCryptoAmount()
?: walletState.walletsDataFromStores.calculateTotalCryptoAmount()
val calculator: IBalanceCalculator = biometricsWalletDataModels
?.let { BiometricsBalanceCalculator(it) }
?: BalanceCalculator(walletState)
return calculator.calculate()
}
private fun BigDecimal.toCardBalanceState(): AnalyticsParam.CardBalanceState = when {
isZero() -> AnalyticsParam.CardBalanceState.Empty
else -> AnalyticsParam.CardBalanceState.Full
}
}
private fun List<WalletDataModel>.calculateTotalCryptoAmount(): BigDecimal {
return this
.map { it.status.amount }
.reduce(BigDecimal::plus)
private interface IBalanceCalculator {
fun calculate(): BigDecimal
}
private class BiometricsBalanceCalculator(
private val walletDataModel: List<WalletDataModel>,
) : IBalanceCalculator {
override fun calculate(): BigDecimal {
val singleToken = walletDataModel
.filter { it.currency.isToken() }
.firstOrNull { it.isCardSingleToken }
val totalAmount = singleToken?.status?.amount
?: walletDataModel.calculateTotalCryptoAmount()
return totalAmount
}
private fun List<WalletDataModel>.calculateTotalCryptoAmount(): BigDecimal = this
.map { it.status.amount }
.reduce(BigDecimal::plus)
}
private class BalanceCalculator(
private val walletState: WalletState,
) : IBalanceCalculator {
override fun calculate(): BigDecimal {
val singleTokenData = walletState.primaryTokenData
val totalAmount = singleTokenData?.currencyData?.amount
?: walletState.walletsDataFromStores.calculateTotalCryptoAmount()
return totalAmount
}
}

View file

@ -10,7 +10,7 @@ sealed class AnalyticsParam {
class Token(token: com.tangem.blockchain.common.Token) : CurrencyType(token.symbol)
class FiatCurrency(
fiatCurrency: com.tangem.tap.common.entities.FiatCurrency,
) : CurrencyType(fiatCurrency.symbol)
) : CurrencyType(fiatCurrency.code)
class Amount(amount: com.tangem.blockchain.common.Amount) : CurrencyType(amount.currencySymbol)
}
@ -63,8 +63,18 @@ sealed class AnalyticsParam {
object BlockchainSdk : Error("Blockchain Sdk Error")
}
sealed class ScannedFrom(val value: String) {
object Introduction : ScannedFrom("Introduction")
object Main : ScannedFrom("Main")
object SignIn : ScannedFrom("Sign In")
object MyWallets : ScannedFrom("My Wallets")
}
companion object Key {
const val BatchId = "Batch"
const val Batch = "Batch"
const val ProductType = "Product Type"
const val Firmware = "Firmware"
const val Currency = "Currency"
const val ErrorDescription = "Error Description"
const val ErrorCode = "Error Code"
const val ErrorKey = "Error Key"

View file

@ -11,6 +11,15 @@ sealed class Basic(
error: Throwable? = null,
) : AnalyticsEvent("Basic", event, params, error) {
class CardWasScanned(
source: AnalyticsParam.ScannedFrom,
) : Basic(
event = "Card Was Scanned",
params = mapOf(
"Source" to source.value,
),
)
class SignedIn(
state: AnalyticsParam.CardBalanceState,
currency: AnalyticsParam.CardCurrency,
@ -19,14 +28,14 @@ sealed class Basic(
event = "Signed in",
params = mapOf(
"State" to state.value,
"Currency" to currency.value,
AnalyticsParam.BatchId to batch,
AnalyticsParam.Currency to currency.value,
AnalyticsParam.Batch to batch,
),
)
class ToppedUp(currency: AnalyticsParam.CardCurrency) : Basic(
event = "Topped up",
params = mapOf("Currency" to currency.value),
params = mapOf(AnalyticsParam.Currency to currency.value),
)
class ScanError(error: Throwable) : Basic(

View file

@ -14,6 +14,5 @@ sealed class IntroductionProcess(
class ButtonTokensList : IntroductionProcess("Button - Tokens List")
class ButtonBuyCards : IntroductionProcess("Button - Buy Cards")
class ButtonScanCard : IntroductionProcess("Button - Scan Card")
class CardWasScanned : IntroductionProcess("Card Was Scanned")
class ButtonRequestSupport : IntroductionProcess("Button - Request Support")
}

View file

@ -13,8 +13,8 @@ sealed class MainScreen(
class ScreenOpened : MainScreen("Screen opened")
class ButtonScanCard : MainScreen("Button - Scan Card")
class CardWasScanned : MainScreen("Card Was Scanned")
class ButtonMyWallets : MainScreen("Button - My Wallets")
class ButtonBuy : MainScreen("Button - Buy")
class EnableBiometrics(state: AnalyticsParam.OnOffState) : MainScreen(
event = "Enable Biometric",

View file

@ -8,7 +8,6 @@ sealed class MyWallets(
) : AnalyticsEvent("My Wallets", event, params) {
class MyWalletsScreenOpened : MyWallets(event = "My Wallets Screen Opened")
class CardWasScanned : MyWallets(event = "Card Was Scanned")
sealed class Button {
class ScanNewCard : MyWallets(event = "Button - Scan New Card")

View file

@ -51,7 +51,7 @@ sealed class Onboarding(
class ButtonBuyCrypto(currency: AnalyticsParam.CurrencyType) : Topup(
event = "Button - Buy Crypto",
params = mapOf("Currency" to currency.value),
params = mapOf(AnalyticsParam.Currency to currency.value),
)
class ButtonShowWalletAddress : Topup("Button - Show the Wallet Address")
@ -67,16 +67,26 @@ sealed class Onboarding(
class SetupFinished : Twins("Twin Setup Finished")
}
class PinCodeSet : Onboarding("Onboarding", "PIN code set")
class PinScreenOpened : Onboarding("Onboarding", "PIN screen opened")
class ButtonSetPinCode : Onboarding("Onboarding", "Button - Set PIN Code")
class CardConnectionScreenOpened : Onboarding("Onboarding", "Card Connection Screen Opened")
class ButtonConnect : Onboarding("Onboarding", "Button - Connect")
class PinCodeSet : Onboarding("Onboarding", "PIN code set")
class KYCScreenOpened : Onboarding("Onboarding", "KYC screen opened")
class KYCStarted : Onboarding("Onboarding", "KYC started")
class KYCInProgress : Onboarding("Onboarding", "KYC in progress")
class KYCRejected : Onboarding("Onboarding", "KYC rejected")
class ClaimScreenOpened : Onboarding("Onboarding", "Claim screen opened")
class ButtonClaim : Onboarding("Onboarding", "Button - Claim")
class ClaimWasSuccessfully : Onboarding("Onboarding", "Claim was successfully")
class ButtonChat : Onboarding("Onboarding", "Button - Chat")
class NotEnoughGasError : Onboarding("Onboarding", "Not Enough Gas Error")
class CardNotPassedError : Onboarding("Onboarding", "Card Not Passed Error")
class EnableBiometrics(state: AnalyticsParam.OnOffState) : Onboarding(
category = "Onboarding / Biometric",
event = "Enable Biometric",

View file

@ -12,7 +12,6 @@ sealed class SignIn(
) : AnalyticsEvent("Sign In", event, params, error) {
class ScreenOpened : SignIn(event = "Sing In Screen Opened")
class CardWasScanned : SignIn(event = "Card Was Scanned")
class ButtonBiometricSignIn : SignIn(event = "Button - Biometric Sign In")
class ButtonCardSignIn : SignIn(event = "Button - Card Sign In")

View file

@ -1,21 +0,0 @@
package com.tangem.tap.common.analytics.paramsInterceptor
import com.tangem.core.analytics.api.ParamsInterceptor
import com.tangem.core.analytics.AnalyticsEvent
import com.tangem.tap.common.analytics.events.AnalyticsParam
/**
[REDACTED_AUTHOR]
*/
class BatchIdParamsInterceptor(
val batchId: String,
) : ParamsInterceptor {
override fun id(): String = this::class.java.simpleName
override fun canBeAppliedTo(event: AnalyticsEvent): Boolean = true
override fun intercept(params: MutableMap<String, String>) {
params[AnalyticsParam.BatchId] = batchId
}
}

View file

@ -0,0 +1,72 @@
package com.tangem.tap.common.analytics.paramsInterceptor
import com.tangem.core.analytics.AnalyticsEvent
import com.tangem.core.analytics.api.ParamsInterceptor
import com.tangem.domain.common.ProductType
import com.tangem.domain.common.ScanResponse
import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.IntroductionProcess
import com.tangem.tap.common.analytics.events.MainScreen
import com.tangem.tap.features.demo.DemoHelper
/**
[REDACTED_AUTHOR]
*/
class CardContextInterceptor(
private val scanResponse: ScanResponse?,
) : ParamsInterceptor {
override fun id(): String = CardContextInterceptor.id()
override fun canBeAppliedTo(event: AnalyticsEvent): Boolean {
return when (event) {
is IntroductionProcess.ButtonScanCard, is MainScreen.ButtonScanCard -> false
else -> true
}
}
override fun intercept(params: MutableMap<String, String>) {
scanResponse ?: return
val card = scanResponse.card
params[AnalyticsParam.Batch] = card.batchId
params[AnalyticsParam.ProductType] = getProductType(scanResponse)
params[AnalyticsParam.Firmware] = card.firmwareVersion.stringValue
ParamCardCurrencyConverter().convert(scanResponse.cardTypesResolver)?.let {
params[AnalyticsParam.Currency] = it.value
}
}
private fun getProductType(scanResponse: ScanResponse): String {
return when (scanResponse.productType) {
ProductType.Note -> "Note"
ProductType.Twins -> "Twin"
ProductType.Wallet -> "Wallet"
ProductType.SaltPay -> if (scanResponse.cardTypesResolver.isSaltPayVisa()) {
"Visa"
} else {
"Visa Backup"
}
ProductType.Start2Coin -> "Start2Coin"
else -> if (DemoHelper.isDemoCard(scanResponse)) {
if (DemoHelper.isTestDemoCard(scanResponse)) {
"Demo Test"
} else {
when (scanResponse.card.cardId.substring(0..1)) {
"AC" -> "Demo Wallet"
"AB" -> "Demo Note"
else -> "Demo Other"
}
}
} else {
"Other"
}
}
}
companion object {
fun id(): String = CardContextInterceptor::class.java.simpleName
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.tap.common.analytics.paramsInterceptor
import com.tangem.core.analytics.AnalyticsEvent
import com.tangem.core.analytics.api.ParamsInterceptor
import com.tangem.domain.common.ScanResponse
/**
[REDACTED_AUTHOR]
*/
class LinkedCardContextInterceptor(
private val scanResponse: ScanResponse,
val parent: LinkedCardContextInterceptor? = null,
) : ParamsInterceptor {
private val contextInterceptor = CardContextInterceptor(scanResponse)
override fun id(): String = LinkedCardContextInterceptor.id()
override fun canBeAppliedTo(event: AnalyticsEvent): Boolean = contextInterceptor.canBeAppliedTo(event)
override fun intercept(params: MutableMap<String, String>) = contextInterceptor.intercept(params)
companion object {
fun id(): String = LinkedCardContextInterceptor::class.java.simpleName
}
}

View file

@ -0,0 +1,53 @@
package com.tangem.tap.common.chat
import android.content.Context
import android.os.Build
import com.tangem.tap.ForegroundActivityObserver
import com.tangem.datasource.config.models.ChatConfig
import com.tangem.tap.common.chat.opener.ChatOpener
import com.tangem.datasource.config.models.SprinklrConfig
import com.tangem.datasource.config.models.ZendeskConfig
import com.tangem.tap.common.chat.opener.implementation.SprinklrChatOpener
import com.tangem.tap.common.chat.opener.implementation.ZendeskChatOpener
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.persistence.PreferencesStorage
import org.rekotlin.Store
class ChatManager(
private val preferencesStorage: PreferencesStorage,
private val foregroundActivityObserver: ForegroundActivityObserver,
private val store: Store<AppState>,
) {
private val openers = mutableMapOf<ChatConfig, ChatOpener>()
fun open(config: ChatConfig, feedbackDataBuilder: (Context) -> String) {
val opener = openers.getOrPut(config) {
when (config) {
is SprinklrConfig -> SprinklrChatOpener(getSprinklrUserId(), config, store, foregroundActivityObserver)
is ZendeskConfig -> ZendeskChatOpener(getZendeskUserId(), config, foregroundActivityObserver)
}
}
opener.open(feedbackDataBuilder)
}
private fun getZendeskUserId(): String {
if (preferencesStorage.zendeskFirstLaunchTime == null) {
preferencesStorage.zendeskFirstLaunchTime = System.currentTimeMillis()
}
return getChatUserId(preferencesStorage.zendeskFirstLaunchTime!!)
}
private fun getSprinklrUserId(): String {
if (preferencesStorage.sprinklrFirstLaunchTime == null) {
preferencesStorage.sprinklrFirstLaunchTime = System.currentTimeMillis()
}
return getChatUserId(preferencesStorage.sprinklrFirstLaunchTime!!)
}
private fun getChatUserId(firstLaunchTimeMillis: Long): String {
return "${firstLaunchTimeMillis}${Build.MODEL}".hashCode().toString()
}
}

View file

@ -0,0 +1,7 @@
package com.tangem.tap.common.chat.opener
import android.content.Context
internal interface ChatOpener {
fun open(feedbackDataBuilder: (Context) -> String)
}

View file

@ -0,0 +1,27 @@
package com.tangem.tap.common.chat.opener.implementation
import android.content.Context
import android.content.Intent
import com.tangem.tap.ForegroundActivityObserver
import com.tangem.tap.common.chat.opener.ChatOpener
import com.tangem.datasource.config.models.SprinklrConfig
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.sprinklr.redux.SprinklrAction
import com.tangem.tap.features.sprinklr.ui.SprinklrActivity
import com.tangem.tap.withForegroundActivity
import org.rekotlin.Store
internal class SprinklrChatOpener(
private val userId: String,
private val config: SprinklrConfig,
private val store: Store<AppState>,
private val foregroundActivityObserver: ForegroundActivityObserver,
) : ChatOpener {
override fun open(feedbackDataBuilder: (Context) -> String) {
store.dispatch(SprinklrAction.Init(userId, config))
foregroundActivityObserver.withForegroundActivity { activity ->
val intent = Intent(activity, SprinklrActivity::class.java)
activity.startActivity(intent)
}
}
}

View file

@ -0,0 +1,72 @@
package com.tangem.tap.common.chat.opener.implementation
import android.content.Context
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.LogConfig
import com.tangem.tap.ForegroundActivityObserver
import com.tangem.tap.common.chat.opener.ChatOpener
import com.tangem.datasource.config.models.ZendeskConfig
import com.tangem.tap.withForegroundActivity
import com.tangem.wallet.R
import com.zendesk.logger.Logger
import zendesk.chat.Chat
import zendesk.chat.ChatConfiguration
import zendesk.chat.ChatEngine
import zendesk.chat.ChatProvidersConfiguration
import zendesk.chat.VisitorInfo
import zendesk.configurations.Configuration
import zendesk.messaging.MessagingActivity
internal class ZendeskChatOpener(
private val userId: String,
private val config: ZendeskConfig,
private val foregroundActivityObserver: ForegroundActivityObserver,
) : ChatOpener {
private var isInitialized = false
override fun open(feedbackDataBuilder: (Context) -> String) {
foregroundActivityObserver.withForegroundActivity { activity ->
initZendeskIfNeeded(activity.applicationContext)
setChatVisitorInfo()
setChatVisitorNote(feedbackDataBuilder(activity))
showMessagingActivity(activity)
}
}
private fun initZendeskIfNeeded(context: Context) {
if (isInitialized) return
isInitialized = true
Chat.INSTANCE.init(context, config.accountKey, config.appId)
Logger.setLoggable(LogConfig.zendesk)
}
private fun setChatVisitorInfo() {
val visitorInfo = VisitorInfo.builder().withName("User $userId").build()
Chat.INSTANCE.chatProvidersConfiguration =
ChatProvidersConfiguration.builder().withVisitorInfo(visitorInfo).build()
}
private fun setChatVisitorNote(note: String) {
Chat.INSTANCE.providers()?.profileProvider()?.setVisitorNote(note)
}
private fun showMessagingActivity(context: Context) {
Analytics.send(com.tangem.tap.common.analytics.events.Chat.ScreenOpened())
MessagingActivity.builder()
.withMultilineResponseOptionsEnabled(false)
.withBotLabelStringRes(R.string.chat_bot_name)
.withBotAvatarDrawable(R.mipmap.ic_launcher)
.withEngines(ChatEngine.engine())
.show(context, buildChatConfig())
}
private fun buildChatConfig(): Configuration {
return ChatConfiguration.builder()
.withOfflineFormEnabled(true)
.withAgentAvailabilityEnabled(true)
.withPreChatFormEnabled(false)
.build()
}
}

View file

@ -0,0 +1,43 @@
package com.tangem.tap.common.extensions
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.ScanResponse
import com.tangem.tap.common.analytics.paramsInterceptor.LinkedCardContextInterceptor
/**
[REDACTED_AUTHOR]
*/
/**
* Sets the new context
*/
fun Analytics.setContext(scanResponse: ScanResponse) {
addParamsInterceptor(LinkedCardContextInterceptor(scanResponse))
}
/**
* Erases the context
*/
fun Analytics.eraseContext() {
removeParamsInterceptor(LinkedCardContextInterceptor.id())
}
/**
* Adds a new context and keeps a previous context as the parent of the new one
*/
fun Analytics.addContext(scanResponse: ScanResponse) {
val currentContext = removeParamsInterceptor(LinkedCardContextInterceptor.id()) as? LinkedCardContextInterceptor
val newContext = LinkedCardContextInterceptor(scanResponse, parent = currentContext)
addParamsInterceptor(newContext)
}
/**
* Removes the current context and restores the previous one if it was present.
*/
fun Analytics.removeContext() {
val currentContext = removeParamsInterceptor(LinkedCardContextInterceptor.id()) as? LinkedCardContextInterceptor
val previousContext = currentContext?.parent ?: return
addParamsInterceptor(previousContext)
}

View file

@ -0,0 +1,32 @@
package com.tangem.tap.common.extensions
import android.widget.ImageView
import coil.ImageLoader
import coil.imageLoader
import coil.load
import coil.request.Disposable
import coil.request.ImageRequest
import com.tangem.tap.features.wallet.redux.Artwork
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
fun ImageRequest.Builder.cardImageData(any: Any?): ImageRequest.Builder = apply {
data(any?.parseData())
}
fun ImageView.loadCardImage(
data: Any?,
imageLoader: ImageLoader = context.imageLoader,
builder: ImageRequest.Builder.() -> Unit = {},
): Disposable = this.load(
data = data?.parseData(),
imageLoader = imageLoader,
builder = builder,
)
private fun Any?.parseData(): Any? = when {
this is String && this == Artwork.SALT_PAY_URL -> R.drawable.img_salt_pay_visa
else -> this
}

View file

@ -2,6 +2,9 @@ package com.tangem.tap.common.extensions
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionHistoryProvider
import com.tangem.blockchain.common.Wallet
import com.tangem.blockchain.common.WalletManager
import com.tangem.common.services.Result
@ -10,6 +13,7 @@ import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.extensions.amountToCreateAccount
import com.tangem.tap.domain.getFirstToken
import com.tangem.tap.features.demo.isDemoCard
import com.tangem.tap.features.wallet.models.Currency
import com.tangem.tap.features.wallet.redux.AddressData
import com.tangem.tap.features.wallet.redux.reducers.createAddressesData
import com.tangem.tap.network.NetworkConnectivity
@ -31,6 +35,9 @@ suspend fun WalletManager.safeUpdate(): Result<Wallet> = try {
Result.Success(wallet)
} else {
update()
if (wallet.blockchain == Blockchain.SaltPay && this is TransactionHistoryProvider) {
this.getTransactionHistory(wallet.address, wallet.blockchain, wallet.getTokens())
}
Result.Success(wallet)
}
} catch (exception: Exception) {
@ -76,10 +83,33 @@ fun WalletManager?.getAddressData(): AddressData? {
return if (addressDataList.isEmpty()) null else addressDataList[0]
}
fun WalletManager.getTxHistory(currency: Currency): List<TransactionData> = wallet.getTxHistory(currency)
fun WalletManager.getBlockchainTxHistory(): List<TransactionData> = wallet.getBlockchainTxHistory()
fun WalletManager.getTokenTxHistory(token: Token): List<TransactionData> = wallet.getTokenTxHistory(token)
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
}
fun Wallet.getTxHistory(currency: Currency): List<TransactionData> {
return (currency as? Currency.Token)?.let { this.getTokenTxHistory(it.token) }
?: getBlockchainTxHistory()
}
fun Wallet.getBlockchainTxHistory(): List<TransactionData> {
return historyTransactions.filter {
it.contractAddress.isNullOrEmpty()
}
}
fun Wallet.getTokenTxHistory(token: Token): List<TransactionData> {
return historyTransactions.filter {
it.contractAddress == token.contractAddress
}
}

View file

@ -1,24 +1,14 @@
package com.tangem.tap.common.feedback
import android.content.Context
import android.os.Build
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.TapWorkarounds
import com.tangem.tap.common.chat.ChatManager
import com.tangem.datasource.config.models.ChatConfig
import com.tangem.tap.common.extensions.sendEmail
import com.tangem.tap.common.log.TangemLogCollector
import com.tangem.datasource.config.models.ZendeskConfig
import com.tangem.tap.foregroundActivityObserver
import com.tangem.tap.persistence.PreferencesStorage
import com.tangem.tap.withForegroundActivity
import com.tangem.wallet.R
import timber.log.Timber
import zendesk.chat.Chat
import zendesk.chat.ChatConfiguration
import zendesk.chat.ChatEngine
import zendesk.chat.ChatProvidersConfiguration
import zendesk.chat.VisitorInfo
import zendesk.configurations.Configuration
import zendesk.messaging.MessagingActivity
import java.io.File
import java.io.FileWriter
import java.io.StringWriter
@ -29,19 +19,8 @@ import java.io.StringWriter
class FeedbackManager(
val infoHolder: AdditionalFeedbackInfo,
private val logCollector: TangemLogCollector,
private val preferencesStorage: PreferencesStorage,
private val chatManager: ChatManager,
) {
private var lastUsedConfigForInitialization: ZendeskConfig? = null
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) {
feedbackData.prepare(infoHolder)
@ -57,12 +36,12 @@ class FeedbackManager(
}
}
fun openChat(feedbackData: FeedbackData) {
feedbackData.prepare(infoHolder)
foregroundActivityObserver.withForegroundActivity { activity ->
setChatVisitorInfo()
setChatVisitorNote(activity, feedbackData)
showMessagingActivity(activity)
fun openChat(config: ChatConfig, feedbackData: FeedbackData) {
chatManager.open(config) { context ->
feedbackData.run {
prepare(infoHolder)
joinTogether(context, infoHolder)
}
}
}
@ -85,47 +64,6 @@ class FeedbackManager(
}
}
private fun setChatVisitorInfo() {
if (preferencesStorage.chatFirstLaunchTime == null) {
preferencesStorage.chatFirstLaunchTime = System.currentTimeMillis()
}
val chatUserId = (preferencesStorage.chatFirstLaunchTime.toString() + Build.MODEL).hashCode()
val visitorInfo = VisitorInfo.builder()
.withName("User $chatUserId")
.build()
Chat.INSTANCE.chatProvidersConfiguration = ChatProvidersConfiguration.builder()
.withVisitorInfo(visitorInfo)
.build()
}
private fun setChatVisitorNote(
context: Context,
feedbackData: FeedbackData,
) {
Chat.INSTANCE.providers()
?.profileProvider()
?.setVisitorNote(feedbackData.joinTogether(context, infoHolder))
}
private fun showMessagingActivity(context: Context) {
Analytics.send(com.tangem.tap.common.analytics.events.Chat.ScreenOpened())
MessagingActivity.builder()
.withMultilineResponseOptionsEnabled(false)
.withBotLabelStringRes(R.string.chat_bot_name)
.withBotAvatarDrawable(R.mipmap.ic_launcher)
.withEngines(ChatEngine.engine())
.show(context, buildChatConfig())
}
private fun buildChatConfig(): Configuration {
return ChatConfiguration.builder()
.withOfflineFormEnabled(true)
.withAgentAvailabilityEnabled(true)
.withPreChatFormEnabled(false)
.build()
}
private fun getSupportEmail(): String {
return if (TapWorkarounds.isStart2CoinIssuer(infoHolder.cardIssuer)) {
S2C_SUPPORT_EMAIL

View file

@ -13,11 +13,12 @@ import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWallet
import com.tangem.tap.features.saveWallet.redux.SaveWalletReducer
import com.tangem.tap.features.send.redux.reducers.SendScreenReducer
import com.tangem.tap.features.shop.redux.ShopReducer
import com.tangem.tap.features.sprinklr.redux.SprinklrReducer
import com.tangem.tap.features.tokens.redux.TokensReducer
import com.tangem.tap.features.wallet.redux.reducers.WalletReducer
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.tap.features.walletSelector.redux.WalletSelectorReducer
import com.tangem.tap.features.welcome.redux.WelcomeReducer
import com.tangem.tap.proxy.AppStateHolder
import org.rekotlin.Action
fun appReducer(action: Action, state: AppState?, appStateHolder: AppStateHolder): AppState {
@ -42,6 +43,7 @@ fun appReducer(action: Action, state: AppState?, appStateHolder: AppStateHolder)
welcomeState = WelcomeReducer.reduce(action, state),
saveWalletState = SaveWalletReducer.reduce(action, state),
walletSelectorState = WalletSelectorReducer.reduce(action, state),
sprinklrState = SprinklrReducer.reduce(action, state),
)
}

View file

@ -31,6 +31,8 @@ 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
import com.tangem.tap.features.shop.redux.ShopState
import com.tangem.tap.features.sprinklr.redux.SprinklrMiddleware
import com.tangem.tap.features.sprinklr.redux.SprinklrState
import com.tangem.tap.features.tokens.redux.TokensMiddleware
import com.tangem.tap.features.tokens.redux.TokensState
import com.tangem.tap.features.wallet.redux.WalletState
@ -61,6 +63,7 @@ data class AppState(
val welcomeState: WelcomeState = WelcomeState(),
val saveWalletState: SaveWalletState = SaveWalletState(),
val walletSelectorState: WalletSelectorState = WalletSelectorState(),
val sprinklrState: SprinklrState = SprinklrState(),
) : StateType {
val domainState: DomainState
@ -101,6 +104,7 @@ data class AppState(
WalletSelectorMiddleware().middleware,
LockUserWalletsTimerMiddleware().middleware,
AccessCodeRequestPolicyMiddleware().middleware,
SprinklrMiddleware().middleware,
)
}
}

View file

@ -4,6 +4,8 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.WalletManager
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemError
import com.tangem.datasource.config.ConfigManager
import com.tangem.datasource.config.models.ChatConfig
import com.tangem.domain.common.ScanResponse
import com.tangem.tap.common.entities.FiatCurrency
import com.tangem.tap.common.feedback.FeedbackData
@ -13,9 +15,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.datasource.config.models.ZendeskConfig
import com.tangem.tap.domain.TapError
import com.tangem.datasource.config.ConfigManager
import com.tangem.tap.domain.configurable.warningMessage.WarningMessage
import com.tangem.tap.domain.configurable.warningMessage.WarningMessagesManager
import com.tangem.tap.features.details.redux.SecurityOption
@ -36,18 +36,20 @@ sealed class GlobalAction : Action {
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
* For resuming unfinished backup of standard Wallet and SaltPay cards see
* BackupAction.CheckForUnfinishedBackup, GlobalAction.Onboarding.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
* Initiate resuming of unfinished backup for standard Wallet and SaltPay cards.
* See more BackupAction.CheckForUnfinishedBackup
*/
data class StartForUnfinishedBackup(val addedBackupCardsCount: Int) : Onboarding()
data class StartForUnfinishedBackup(
val addedBackupCardsCount: Int,
val isSaltPayVisa: Boolean,
) : Onboarding()
object Stop : Onboarding()
}
@ -87,7 +89,7 @@ sealed class GlobalAction : Action {
data class SetFeedbackManager(val feedbackManager: FeedbackManager) : GlobalAction()
data class SendEmail(val feedbackData: FeedbackData) : GlobalAction()
data class OpenChat(val feedbackData: FeedbackData, val zendeskConfig: ZendeskConfig? = null) : GlobalAction()
data class OpenChat(val feedbackData: FeedbackData, val chatConfig: ChatConfig? = null) : GlobalAction()
data class UpdateFeedbackInfo(val walletManagers: List<WalletManager>) : GlobalAction()
object ExchangeManager : GlobalAction() {

View file

@ -3,8 +3,11 @@ package com.tangem.tap.common.redux.global
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.guard
import com.tangem.common.extensions.ifNotNull
import com.tangem.datasource.config.models.Config
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.LogConfig
import com.tangem.domain.common.ProductType
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.tap.common.extensions.dispatchDebugErrorNotification
import com.tangem.tap.common.extensions.dispatchDialogShow
@ -14,16 +17,22 @@ import com.tangem.tap.common.redux.AppState
import com.tangem.tap.domain.configurable.warningMessage.WarningMessagesManager
import com.tangem.tap.features.send.redux.SendAction
import com.tangem.tap.features.wallet.redux.WalletAction
import com.tangem.tap.network.exchangeServices.BuyExchangeService
import com.tangem.tap.network.exchangeServices.CardExchangeRules
import com.tangem.tap.network.exchangeServices.CurrencyExchangeManager
import com.tangem.tap.network.exchangeServices.mercuryo.MercuryoApi
import com.tangem.tap.network.exchangeServices.ExchangeService
import com.tangem.tap.network.exchangeServices.mercuryo.MercuryoEnvironment
import com.tangem.tap.network.exchangeServices.mercuryo.MercuryoService
import com.tangem.tap.network.exchangeServices.moonpay.MoonPayService
import com.tangem.tap.network.exchangeServices.utorg.UtorgAuthProvider
import com.tangem.tap.network.exchangeServices.utorg.UtorgEnvironment
import com.tangem.tap.network.exchangeServices.utorg.UtorgExchangeService
import com.tangem.tap.preferencesStorage
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.tap.tangemSdkManager
import com.tangem.tap.userTokensRepository
import com.tangem.wallet.BuildConfig
import kotlinx.coroutines.launch
import org.rekotlin.Action
import org.rekotlin.DispatchFunction
@ -98,17 +107,16 @@ private fun handleAction(action: Action, appState: () -> AppState?, dispatch: Di
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?.cardTypesResolver?.isSaltPay() == true -> config.saltPayConfig?.zendesk
val unsafeChatConfig = action.chatConfig ?: when {
scanResponse?.cardTypesResolver?.isSaltPay() == true -> config.saltPayConfig?.sprinklr
else -> config.zendesk
}
val zendeskConfig = unsafeZendeskConfig.guard {
val chatConfig = unsafeChatConfig.guard {
store.dispatchDebugErrorNotification("ZendeskConfig not initialized")
return
}
feedbackManager.initChat(zendeskConfig)
feedbackManager.openChat(action.feedbackData)
feedbackManager.openChat(chatConfig, action.feedbackData)
}
is GlobalAction.UpdateWalletSignedHashes -> {
store.dispatch(WalletAction.Warnings.CheckRemainingSignatures(action.remainingSignatures))
@ -119,38 +127,23 @@ private fun handleAction(action: Action, appState: () -> AppState?, dispatch: Di
}
is GlobalAction.ExchangeManager.Init -> {
val appStateSafe = appState() ?: return
val config = appStateSafe.globalState.configManager?.config
ifNotNull(
config?.mercuryoWidgetId,
config?.mercuryoSecret,
config?.moonPayApiKey,
config?.moonPayApiSecretKey,
) { mercuryoWidgetId, mercuryoSecret, moonPayKey, moonPaySecretKey ->
scope.launch {
val buyService = MercuryoService(
apiVersion = MercuryoApi.API_VERSION,
mercuryoWidgetId = mercuryoWidgetId,
secret = mercuryoSecret,
logEnabled = LogConfig.network.mercuryoService,
)
val sellService = MoonPayService(
apiKey = moonPayKey,
secretKey = moonPaySecretKey,
logEnabled = LogConfig.network.moonPayService,
)
val cardProvider = {
store.state.globalState.scanResponse?.card
?: store.state.globalState.onboardingState.onboardingManager?.scanResponse?.card
}
val config = appStateSafe.globalState.configManager?.config ?: return
val exchangeManager = CurrencyExchangeManager(
buyService = buyService,
sellService = sellService,
primaryRules = CardExchangeRules(cardProvider),
)
store.dispatchOnMain(GlobalAction.ExchangeManager.Init.Success(exchangeManager))
store.dispatchOnMain(GlobalAction.ExchangeManager.Update)
scope.launch {
val scanResponseProvider: () -> ScanResponse? = {
store.state.globalState.scanResponse
?: store.state.globalState.onboardingState.onboardingManager?.scanResponse
}
val productTypeProvider: () -> ProductType? = { scanResponseProvider.invoke()?.productType }
val cardProvider: () -> CardDTO? = { scanResponseProvider.invoke()?.card }
val exchangeManager = CurrencyExchangeManager(
buyService = makeBuyExchangeService(config, productTypeProvider),
sellService = makeSellExchangeService(config),
primaryRules = CardExchangeRules(cardProvider),
)
store.dispatchOnMain(GlobalAction.ExchangeManager.Init.Success(exchangeManager))
store.dispatchOnMain(GlobalAction.ExchangeManager.Update)
}
}
is GlobalAction.ExchangeManager.Init.Success -> {}
@ -202,4 +195,38 @@ private fun handleAction(action: Action, appState: () -> AppState?, dispatch: Di
}
}
}
}
private fun makeSellExchangeService(config: Config): ExchangeService {
return MoonPayService(
apiKey = config.moonPayApiKey,
secretKey = config.moonPayApiSecretKey,
logEnabled = LogConfig.network.moonPayService,
)
}
private fun makeBuyExchangeService(config: Config, productTypeProvider: () -> ProductType?): ExchangeService {
return BuyExchangeService(
productTypeProvider = productTypeProvider,
mercuryoService = makeMercuryoExchangeService(config),
utorgService = makeUtorgExchangeService(config),
)
}
private fun makeMercuryoExchangeService(config: Config): MercuryoService {
val mercuryoEnvironment = MercuryoEnvironment.prod(config.mercuryoWidgetId, config.mercuryoSecret)
return MercuryoService(mercuryoEnvironment)
}
private fun makeUtorgExchangeService(config: Config): UtorgExchangeService {
val saltPayConfig = requireNotNull(config.saltPayConfig)
val utorgAuthProvider = UtorgAuthProvider(saltPayConfig.kycProvider.sidValue)
val utorgEnvironment = if (BuildConfig.DEBUG) {
UtorgEnvironment.stage(utorgAuthProvider, LogConfig.network.utorgService)
// UtorgEnvironment.mock()
} else {
UtorgEnvironment.prod(utorgAuthProvider)
}
return UtorgExchangeService(utorgEnvironment)
}

View file

@ -10,6 +10,7 @@ import com.tangem.blockchain.common.WalletManagerFactory
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.common.services.Result
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.TapWorkarounds.isTestCard
@ -18,6 +19,7 @@ import com.tangem.domain.common.extensions.withMainContext
import com.tangem.operations.attestation.Attestation
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.safeUpdate
import com.tangem.tap.common.extensions.setContext
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.datasource.config.ConfigManager
import com.tangem.tap.domain.extensions.makePrimaryWalletManager
@ -100,6 +102,7 @@ class TapWalletManager {
}
suspend fun onWalletSelected(userWallet: UserWallet, refresh: Boolean) {
Analytics.setContext(userWallet.scanResponse)
val scanResponse = userWallet.scanResponse
val card = scanResponse.card
val attestationFailed = card.attestation.status == Attestation.Status.Failed

View file

@ -1,19 +1,15 @@
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.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.CardDTO
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
@ -138,24 +134,4 @@ private fun selectWallet(wallets: List<CardDTO.Wallet>): CardDTO.Wallet? {
1 -> wallets[0]
else -> wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } ?: wallets[0]
}
}
fun WalletManagerFactory.makeSaltPayWalletManager(
scanResponse: ScanResponse,
): EthereumWalletManager {
val blockchain = scanResponse.cardTypesResolver.getBlockchain()
if (blockchain != Blockchain.SaltPay) {
error("WalletManager for the SaltPay can be created based only on Blockchain.SaltPay")
}
val token = SaltPayWorkaround.tokenFrom(blockchain)
val cardWallet = scanResponse.card.wallets.firstOrNull().guard {
error("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

@ -1,5 +1,6 @@
package com.tangem.tap.domain.model
import com.tangem.blockchain.common.TransactionData
import com.tangem.tap.domain.model.WalletDataModel.Status
import com.tangem.tap.features.wallet.models.Currency
import com.tangem.tap.features.wallet.models.PendingTransaction
@ -23,6 +24,7 @@ data class WalletDataModel(
val existentialDeposit: BigDecimal?,
val fiatRate: BigDecimal?,
val isCardSingleToken: Boolean,
val historyTransactions: List<TransactionData>?,
) {
/**

View file

@ -4,6 +4,7 @@ import com.tangem.common.extensions.toHexString
import com.tangem.common.services.Result
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.ProductType
import com.tangem.domain.common.SaltPayWorkaround
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.common.TwinCardNumber
@ -77,6 +78,7 @@ class UserWalletBuilder(
return when {
cardId.startsWith(Artwork.SERGIO_CARD_ID) -> Artwork.SERGIO_CARD_URL
cardId.startsWith(Artwork.MARTA_CARD_ID) -> Artwork.MARTA_CARD_URL
SaltPayWorkaround.isSaltPayCardId(cardId) -> Artwork.SALT_PAY_URL
else -> when (TwinsHelper.getTwinCardNumber(cardId)) {
TwinCardNumber.First -> Artwork.TWIN_CARD_1
TwinCardNumber.Second -> Artwork.TWIN_CARD_2

View file

@ -6,6 +6,8 @@ import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.WalletManager
import com.tangem.common.hdWallet.DerivationPath
import com.tangem.domain.common.util.UserWalletId
import com.tangem.tap.common.extensions.getBlockchainTxHistory
import com.tangem.tap.common.extensions.getTokenTxHistory
import com.tangem.tap.domain.model.WalletDataModel
import com.tangem.tap.domain.model.WalletStoreModel
import com.tangem.tap.domain.tokens.models.BlockchainNetwork
@ -98,6 +100,7 @@ private fun BlockchainNetwork.getBlockchainWalletData(walletManager: WalletManag
existentialDeposit = getExistentialDeposit(walletManager),
fiatRate = null,
isCardSingleToken = false,
historyTransactions = walletManager?.getBlockchainTxHistory(),
)
}
@ -115,6 +118,7 @@ private fun BlockchainNetwork.getTokensWalletsData(walletManager: WalletManager?
existentialDeposit = getExistentialDeposit(walletManager),
fiatRate = null,
isCardSingleToken = walletManager?.cardTokens?.contains(token) ?: false,
historyTransactions = walletManager?.getTokenTxHistory(token),
)
}
}
@ -131,6 +135,7 @@ private fun Blockchain.toBlockchainWalletData(walletManager: WalletManager): Wal
existentialDeposit = getExistentialDeposit(walletManager),
fiatRate = null,
isCardSingleToken = false,
historyTransactions = walletManager.getBlockchainTxHistory(),
)
}
@ -147,6 +152,7 @@ private fun Token.toTokenWalletData(walletManager: WalletManager): WalletDataMod
existentialDeposit = getExistentialDeposit(walletManager),
fiatRate = null,
isCardSingleToken = walletManager.cardTokens.contains(this),
historyTransactions = walletManager.getTokenTxHistory(this),
)
}

View file

@ -13,9 +13,11 @@ import com.tangem.domain.common.extensions.withMainContext
import com.tangem.operations.backup.BackupService
import com.tangem.tap.DELAY_SDK_DIALOG_CLOSE
import com.tangem.tap.backupService
import com.tangem.tap.common.analytics.paramsInterceptor.BatchIdParamsInterceptor
import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor
import com.tangem.tap.common.extensions.addContext
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.primaryCardIsSaltPayVisa
import com.tangem.tap.common.extensions.setContext
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.common.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.NavigationAction
@ -27,10 +29,10 @@ 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.SaltPayActivationManagerFactory
import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayExceptionHandler
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayActivationError
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.preferencesStorage
import com.tangem.tap.scope
import com.tangem.tap.store
@ -75,7 +77,7 @@ object ScanCardProcessor {
tangemSdkManager.changeDisplayedCardIdNumbersCount(scanResponse)
onScanStateChange(false)
sendAnalytics(analyticsEvent, scanResponse.card.batchId)
sendAnalytics(analyticsEvent, scanResponse)
checkForUnfinishedBackupForSaltPay(
backupService = backupService,
@ -103,10 +105,17 @@ object ScanCardProcessor {
private fun sendAnalytics(
analyticsEvent: AnalyticsEvent?,
batchId: String,
scanResponse: ScanResponse,
) {
Analytics.addParamsInterceptor(BatchIdParamsInterceptor(batchId))
analyticsEvent?.let { Analytics.send(it) }
analyticsEvent?.let {
// this workaround needed to send CardWasScannedEvent without adding a context
val interceptor = CardContextInterceptor(scanResponse)
val params = it.params.toMutableMap()
interceptor.intercept(params)
it.params = params.toMap()
Analytics.send(it)
}
}
/**
@ -188,20 +197,25 @@ object ScanCardProcessor {
store.dispatchOnMain(TwinCardsAction.IfTwinsPrepareState(scanResponse))
if (scanResponse.cardTypesResolver.isSaltPay()) {
if (scanResponse.cardTypesResolver.isSaltPayVisa()) {
val (manager, config) = OnboardingSaltPayState.initDependency(scanResponse)
val cardTypesResolver = scanResponse.cardTypesResolver
if (cardTypesResolver.isSaltPay()) {
if (cardTypesResolver.isSaltPayVisa()) {
val manager = SaltPayActivationManagerFactory(
blockchain = scanResponse.cardTypesResolver.getBlockchain(),
card = scanResponse.card,
).create()
val result = OnboardingSaltPayHelper.isOnboardingCase(scanResponse, manager)
delay(500)
withMainContext {
when (result) {
is Result.Success -> {
Analytics.addContext(scanResponse)
val isOnboardingCase = result.data
if (isOnboardingCase) {
onWalletNotCreated()
store.dispatch(GlobalAction.Onboarding.Start(scanResponse, canSkipBackup = false))
store.dispatch(OnboardingSaltPayAction.SetDependencies(manager, config))
store.dispatch(OnboardingSaltPayAction.Update)
store.dispatch(OnboardingSaltPayAction.SetDependencies(manager))
store.dispatch(OnboardingSaltPayAction.Update(withAnalytics = false))
navigateTo(AppScreen.OnboardingWallet) { onProgressStateChange(it) }
} else {
delay(DELAY_SDK_DIALOG_CLOSE)
@ -222,16 +236,19 @@ object ScanCardProcessor {
SaltPayExceptionHandler.handle(error)
onFailure(TangemSdkError.ExceptionError(error))
} else {
Analytics.setContext(scanResponse)
onSuccess(scanResponse)
}
}
} else {
if (OnboardingHelper.isOnboardingCase(scanResponse)) {
Analytics.addContext(scanResponse)
onWalletNotCreated()
store.dispatchOnMain(GlobalAction.Onboarding.Start(scanResponse, canSkipBackup = true))
val appScreen = OnboardingHelper.whereToNavigate(scanResponse)
navigateTo(appScreen) { onProgressStateChange(it) }
} else {
Analytics.setContext(scanResponse)
if (scanResponse.twinsIsTwinned() && !preferencesStorage.wasTwinsOnboardingShown()) {
onWalletNotCreated()
store.dispatchOnMain(TwinCardsAction.SetStepOfScreen(TwinCardsStep.WelcomeOnly(scanResponse)))

View file

@ -19,6 +19,7 @@ import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.util.UserWalletId
import com.tangem.tap.common.entities.FiatCurrency
import com.tangem.tap.common.extensions.replaceByOrAdd
import com.tangem.tap.common.extensions.safeUpdate
import com.tangem.tap.domain.model.UserWallet
import com.tangem.tap.domain.model.WalletStoreModel
import com.tangem.tap.domain.walletStores.WalletStoresError
@ -31,6 +32,7 @@ import com.tangem.tap.domain.walletStores.repository.implementation.utils.update
import com.tangem.tap.domain.walletStores.repository.implementation.utils.updateWithFiatRates
import com.tangem.tap.domain.walletStores.repository.implementation.utils.updateWithMissedDerivation
import com.tangem.tap.domain.walletStores.repository.implementation.utils.updateWithRent
import com.tangem.tap.domain.walletStores.repository.implementation.utils.updateWithTxHistories
import com.tangem.tap.domain.walletStores.repository.implementation.utils.updateWithUnreachable
import com.tangem.tap.domain.walletStores.storage.WalletManagerStorage
import com.tangem.tap.domain.walletStores.storage.WalletStoresStorage
@ -204,7 +206,7 @@ internal class DefaultWalletAmountsRepository(
updateWalletStoreWithUnreachable(walletStore)
}
else -> {
withInternetConnection { walletManager.update() }
withInternetConnection { walletManager.safeUpdate() }
.map { updateWalletManagerWithAmounts(userWalletId, walletManager) }
.flatMap {
updateWalletStoreWithAmounts(
@ -319,7 +321,7 @@ internal class DefaultWalletAmountsRepository(
it.updateWithDemoAmounts(wallet = updatedWallet)
} else {
it.updateWithAmounts(wallet = updatedWallet)
}
}.updateWithTxHistories(wallet = updatedWallet)
},
)
}

View file

@ -4,6 +4,8 @@ import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.common.Wallet
import com.tangem.common.core.TangemError
import com.tangem.tap.common.extensions.getBlockchainTxHistory
import com.tangem.tap.common.extensions.getTokenTxHistory
import com.tangem.tap.domain.extensions.amountToCreateAccount
import com.tangem.tap.domain.getFirstToken
import com.tangem.tap.domain.model.WalletDataModel
@ -29,6 +31,29 @@ internal fun List<WalletDataModel>.updateWithFiatRates(
}
}
internal fun WalletDataModel.updateWithTxHistory(wallet: Wallet): WalletDataModel {
return this.copy(
historyTransactions = when (currency) {
is Currency.Blockchain -> wallet.getBlockchainTxHistory()
is Currency.Token -> wallet.getTokenTxHistory(currency.token)
},
)
}
internal fun List<WalletDataModel>.updateWithTxHistories(
wallet: Wallet,
): List<WalletDataModel> {
return this.map { walletData ->
walletData.updateWithTxHistory(wallet)
}
}
internal fun List<WalletDataModel>.updateWithAmounts(wallet: Wallet): List<WalletDataModel> {
return this.map { walletData ->
walletData.updateWithAmount(wallet)
}
}
internal fun WalletDataModel.updateWithAmount(wallet: Wallet): WalletDataModel {
val pendingTransactions = wallet.getPendingTransactions()
return this.copy(
@ -82,12 +107,6 @@ internal fun WalletDataModel.updateWithDemoAmount(wallet: Wallet): WalletDataMod
)
}
internal fun List<WalletDataModel>.updateWithAmounts(wallet: Wallet): List<WalletDataModel> {
return this.map { walletData ->
walletData.updateWithAmount(wallet)
}
}
internal fun List<WalletDataModel>.updateWithDemoAmounts(wallet: Wallet): List<WalletDataModel> {
return this.map { walletData ->
walletData.updateWithDemoAmount(wallet)

View file

@ -41,6 +41,14 @@ internal fun WalletStoreModel.updateWithError(
)
}
internal fun WalletStoreModel.updateWithTxHistories(
wallet: Wallet,
): WalletStoreModel {
return this.copy(
walletsData = walletsData.updateWithTxHistories(wallet = wallet),
)
}
internal fun WalletStoreModel.updateWithAmounts(
wallet: Wallet,
): WalletStoreModel {

View file

@ -48,6 +48,8 @@ object DemoHelper {
fun isDemoCard(scanResponse: ScanResponse): Boolean = isDemoCardId(scanResponse.card.cardId)
fun isTestDemoCard(scanResponse: ScanResponse): Boolean = config.isTestDemoCardId(scanResponse.card.cardId)
fun isDemoCardId(cardId: String): Boolean = config.isDemoCardId(cardId)
fun tryHandle(appState: () -> AppState?, action: Action): Boolean {
@ -108,6 +110,8 @@ class DemoConfig {
fun isDemoCardId(cardId: String): Boolean = demoCardIds.contains(cardId)
fun isTestDemoCardId(cardId: String): Boolean = testDemoCardIds.contains(cardId)
fun getBalance(blockchain: Blockchain): Amount = walletBalances[blockchain]?.copy()
?: Amount(BigDecimal.ZERO, blockchain).copy()
@ -118,9 +122,6 @@ class DemoConfig {
@Suppress("ClassOrdering")
private val releaseDemoCardIds = mutableListOf(
// === Not from the Google Sheet table ===
"FB10000000000196", // Note BTC
"FB20000000000186", // Note ETH
"FB30000000000176", // Wallet
"AC01000000041225",
"AC01000000041472",
"AB01000000046498",

View file

@ -24,8 +24,6 @@ sealed class DetailsAction : Action {
object Success : ResetToFactory()
}
object CreateBackup : DetailsAction()
object ScanCard : DetailsAction()
data class PrepareCardSettingsData(val card: CardDTO, val cardTypesResolver: CardTypesResolver) : DetailsAction()

View file

@ -66,12 +66,6 @@ class DetailsMiddleware {
store.dispatch(TwinCardsAction.SetMode(CreateTwinWalletMode.RecreateWallet))
store.dispatch(NavigationAction.NavigateTo(AppScreen.OnboardingTwins))
}
is DetailsAction.CreateBackup -> {
state.scanResponse?.let {
store.dispatch(GlobalAction.Onboarding.Start(it, canSkipBackup = false))
store.dispatch(NavigationAction.NavigateTo(AppScreen.OnboardingWallet))
}
}
DetailsAction.ScanCard -> {
scope.launch {
tangemSdkManager.scanProduct(userTokensRepository)

View file

@ -8,7 +8,6 @@ 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.NavigationAction
import com.tangem.tap.features.details.redux.DetailsAction
import com.tangem.tap.features.details.redux.DetailsState
import com.tangem.tap.features.disclaimer.redux.DisclaimerAction
import com.tangem.tap.features.home.LocaleRegionProvider
@ -86,7 +85,7 @@ class DetailsViewModel(private val store: Store<AppState>) {
}
SettingsElement.LinkMoreCards -> {
Analytics.send(Settings.ButtonCreateBackup())
store.dispatch(DetailsAction.CreateBackup)
store.dispatch(WalletAction.MultiWallet.BackupWallet)
}
SettingsElement.TermsOfService -> {
store.dispatch(DisclaimerAction.Show(AppScreen.Details))

View file

@ -29,7 +29,7 @@ class HomeFragment : Fragment(), StoreSubscriber<HomeState> {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Analytics.send(IntroductionProcess.ScreenOpened())
store.dispatch(HomeAction.OnCreate)
store.dispatch(HomeAction.Init)
}

View file

@ -1,16 +1,18 @@
package com.tangem.tap.features.home.redux
import com.tangem.core.analytics.AnalyticsEvent
import com.tangem.tap.common.analytics.events.IntroductionProcess
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Basic
import com.tangem.tap.common.entities.IndeterminateProgressButton
import org.rekotlin.Action
sealed class HomeAction : Action {
object OnCreate : HomeAction()
object Init : HomeAction()
data class ReadCard(
val analyticsEvent: AnalyticsEvent? = IntroductionProcess.CardWasScanned(),
val analyticsEvent: AnalyticsEvent? = Basic.CardWasScanned(AnalyticsParam.ScannedFrom.Introduction),
) : HomeAction()
data class ScanInProgress(val scanInProgress: Boolean) : HomeAction()

View file

@ -5,10 +5,12 @@ import com.tangem.common.doOnResult
import com.tangem.common.doOnSuccess
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.AnalyticsEvent
import com.tangem.tap.common.analytics.events.IntroductionProcess
import com.tangem.tap.common.analytics.events.Shop
import com.tangem.tap.common.entities.IndeterminateProgressButton
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.dispatchOpenUrl
import com.tangem.tap.common.extensions.eraseContext
import com.tangem.tap.common.extensions.onCardScanned
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.redux.AppState
@ -49,6 +51,10 @@ private val homeMiddleware: Middleware<AppState> = { _, _ ->
private fun handleHomeAction(action: Action) {
when (action) {
is HomeAction.OnCreate -> {
Analytics.eraseContext()
Analytics.send(IntroductionProcess.ScreenOpened())
}
is HomeAction.Init -> {
store.dispatch(GlobalAction.RestoreAppCurrency)
store.dispatch(GlobalAction.ExchangeManager.Init)

View file

@ -1,9 +1,12 @@
package com.tangem.tap.features.onboarding
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.ProductType
import com.tangem.domain.common.ScanResponse
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.onCardScanned
import com.tangem.tap.common.extensions.removeContext
import com.tangem.tap.common.extensions.setContext
import com.tangem.tap.common.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.features.saveWallet.redux.SaveWalletAction
@ -55,6 +58,7 @@ object OnboardingHelper {
accessCode: String? = null,
backupCardsIds: List<String>? = null,
) {
Analytics.setContext(scanResponse)
when {
// When should save user wallets, then save card without navigate to save wallet screen
preferencesStorage.shouldSaveUserWallets -> scope.launch {
@ -92,4 +96,8 @@ object OnboardingHelper {
store.dispatchOnMain(NavigationAction.NavigateTo(AppScreen.Wallet))
}
fun onInterrupted() {
Analytics.removeContext()
}
}

View file

@ -13,16 +13,20 @@ import com.tangem.tap.preferencesStorage
[REDACTED_AUTHOR]
*/
object OnboardingSaltPayHelper {
suspend fun isOnboardingCase(scanResponse: ScanResponse, manager: SaltPayActivationManager): Result<Boolean> {
suspend fun isOnboardingCase(
scanResponse: ScanResponse,
manager: SaltPayActivationManager,
): Result<Boolean> {
return try {
var updatedStep = manager.update(SaltPayActivationStep.None, null).successOr { return it }
val updatedStep = manager.update(SaltPayActivationStep.None, null).successOr { return it }
val cardStorage = preferencesStorage.usedCardsPrefStorage
val activationIsFinished = cardStorage.isActivationFinished(scanResponse.card.cardId)
if (updatedStep == SaltPayActivationStep.Success && activationIsFinished) {
updatedStep = SaltPayActivationStep.Finished
if (updatedStep == SaltPayActivationStep.Success && !activationIsFinished) {
cardStorage.activationFinished(scanResponse.card.cardId)
}
val isActivationCase = updatedStep != SaltPayActivationStep.Finished
val isActivationCase = updatedStep != SaltPayActivationStep.Success
val isBackupCase = scanResponse.card.backupStatus?.isActive == false
Result.Success(isActivationCase || isBackupCase)
} catch (ex: Exception) {
@ -31,4 +35,16 @@ object OnboardingSaltPayHelper {
Result.Failure(error)
}
}
@Suppress("UnusedPrivateMember")
fun testProceedToOnboarding(
scanResponse: ScanResponse,
manager: SaltPayActivationManager,
): Result<Boolean> = Result.Success(true)
@Suppress("UnusedPrivateMember")
fun testProceedToMainScreen(
scanResponse: ScanResponse,
manager: SaltPayActivationManager,
): Result<Boolean> = Result.Success(false)
}

View file

@ -196,6 +196,7 @@ private fun handleNoteAction(appState: () -> AppState?, action: Action, dispatch
store.dispatchDialogShow(
OnboardingDialog.InterruptOnboarding(
onOk = {
OnboardingHelper.onInterrupted()
store.dispatch(NavigationAction.PopBackTo())
},
),

View file

@ -118,4 +118,9 @@ class OnboardingOtherCardsFragment : BaseOnboardingFragment<OnboardingOtherCards
transition.interpolator = OvershootInterpolator()
TransitionManager.beginDelayedTransition(onboardingWalletContainer, transition)
}
override fun handleOnBackPressed() {
store.dispatch(OnboardingOtherCardsAction.OnBackPressed)
super.handleOnBackPressed()
}
}

View file

@ -15,6 +15,7 @@ sealed class OnboardingOtherCardsAction : Action {
class SetArtworkUrl(val artworkUrl: String) : OnboardingOtherCardsAction()
object DetermineStepOfScreen : OnboardingOtherCardsAction()
object Done : OnboardingOtherCardsAction()
object OnBackPressed : OnboardingOtherCardsAction()
data class SetStepOfScreen(val step: OnboardingOtherCardsStep) : OnboardingOtherCardsAction()

View file

@ -119,9 +119,7 @@ private fun handleOtherCardsAction(action: Action) {
delay(DELAY_SDK_DIALOG_CLOSE)
store.dispatch(OnboardingOtherCardsAction.SetStepOfScreen(OnboardingOtherCardsStep.Done))
}
is CompletionResult.Failure -> {
// do nothing
}
is CompletionResult.Failure -> Unit
}
}
}
@ -130,9 +128,9 @@ private fun handleOtherCardsAction(action: Action) {
store.dispatch(GlobalAction.Onboarding.Stop)
OnboardingHelper.trySaveWalletAndNavigateToWalletScreen(onboardingManager.scanResponse)
}
is OnboardingOtherCardsAction.Confetti.Hide,
is OnboardingOtherCardsAction.SetArtworkUrl,
is OnboardingOtherCardsAction.Confetti.Show,
-> Unit
OnboardingOtherCardsAction.OnBackPressed -> {
OnboardingHelper.onInterrupted()
}
else -> Unit
}
}

View file

@ -3,8 +3,8 @@ package com.tangem.tap.features.onboarding.products.twins.redux
import com.tangem.Message
import com.tangem.blockchain.common.WalletManager
import com.tangem.common.extensions.VoidCallback
import com.tangem.domain.common.ScanResponse
import com.tangem.datasource.utils.AssetReader
import com.tangem.domain.common.ScanResponse
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.twins.TwinCardsManager
import com.tangem.tap.features.onboarding.OnboardingWalletBalance

View file

@ -320,6 +320,7 @@ private fun handle(action: Action, dispatch: DispatchFunction) {
twinCardsState.currentStep != TwinCardsStep.TopUpWallet &&
twinCardsState.currentStep != TwinCardsStep.Done
OnboardingHelper.onInterrupted()
store.dispatch(TwinCardsAction.CardsManager.Release)
action.shouldResetTwinCardsWidget(shouldReturnCardBack) {
store.dispatch(NavigationAction.PopBackTo(AppScreen.Home))

View file

@ -411,7 +411,11 @@ class TwinsCardsFragment : BaseOnboardingFragment<TwinCardsState>() {
TwinCardsAction.OnBackPressed { should, popAction ->
store.dispatch(TwinCardsAction.Confetti.Hide)
showConfetti(false)
if (should) switchToCard(TwinCardNumber.First, true, popAction) else popAction()
if (should) {
switchToCard(TwinCardNumber.First, true, popAction)
} else {
popAction()
}
},
)
}

View file

@ -31,9 +31,8 @@ sealed class BackupAction : Action {
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
* Check for unfinished backup of standard Wallets and SaltPay cards
* See more GlobalAction.Onboarding.StartForUnfinishedBackup
*/
object CheckForUnfinishedBackup : BackupAction()
@ -65,7 +64,11 @@ sealed class BackupAction : Action {
data class PrepareToWriteBackupCard(val cardNumber: Int) : BackupAction()
data class WriteBackupCard(val cardNumber: Int) : BackupAction()
object FinishBackup : BackupAction()
/**
* It always calls for the SaltPay cards when resuming the activation process.
*/
data class FinishBackup(val withAnalytics: Boolean = true) : BackupAction()
object DiscardBackup : BackupAction()
object DiscardSavedBackup : BackupAction()
object ResumeFoundUnfinishedBackup : BackupAction()

View file

@ -24,8 +24,8 @@ import com.tangem.tap.features.demo.DemoHelper
import com.tangem.tap.features.home.redux.HomeAction
import com.tangem.tap.features.onboarding.OnboardingDialog
import com.tangem.tap.features.onboarding.OnboardingHelper
import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayActivationManagerFactory
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
@ -72,7 +72,7 @@ private fun handleWalletAction(action: Action, state: () -> AppState?, dispatch:
}
when {
card == null -> {
// it's possible when found unfinished backup for standard Wallet cards
// it's possible when found unfinished backup
store.dispatch(OnboardingWalletAction.ResumeBackup)
}
card.wallets.isNotEmpty() && card.backupStatus == CardDTO.BackupStatus.NoBackup -> {
@ -87,10 +87,11 @@ private fun handleWalletAction(action: Action, state: () -> AppState?, dispatch:
scanResponse.cardTypesResolver.isSaltPay() -> {
store.dispatch(OnboardingWalletAction.GetToSaltPayStep)
store.dispatch(BackupAction.FinishBackup)
store.dispatch(BackupAction.FinishBackup(withAnalytics = false))
store.dispatch(OnboardingSaltPayAction.OnSwitchedToSaltPayProcess)
}
else -> store.dispatch(BackupAction.FinishBackup)
else -> store.dispatch(BackupAction.FinishBackup())
}
}
else -> {
@ -317,7 +318,7 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
is CompletionResult.Success -> {
if (backupService.currentState == BackupService.State.Finished) {
initSaltPayOnBackupFinishedIfNeeded(scanResponse, onboardingWalletState)
store.dispatchOnMain(BackupAction.FinishBackup)
store.dispatchOnMain(BackupAction.FinishBackup())
} else {
store.dispatchOnMain(BackupAction.PrepareToWriteBackupCard(action.cardNumber + 1))
}
@ -328,7 +329,9 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
}
}
is BackupAction.FinishBackup -> {
Analytics.send(Onboarding.Backup.Finished(backupState.backupCardsNumber))
if (action.withAnalytics) {
Analytics.send(Onboarding.Backup.Finished(backupState.backupCardsNumber))
}
if (!onboardingWalletState.isSaltPay) {
Analytics.send(Onboarding.Finished())
finishCardActivation(backupState, card)
@ -345,18 +348,23 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction)
backupService.discardSavedBackup()
}
is BackupAction.CheckForUnfinishedBackup -> {
if (backupService.hasIncompletedBackup && !backupService.primaryCardIsSaltPayVisa()) {
if (backupService.hasIncompletedBackup) {
store.dispatch(GlobalAction.ShowDialog(BackupDialog.UnfinishedBackupFound))
}
}
is BackupAction.ResumeFoundUnfinishedBackup -> {
store.dispatch(GlobalAction.Onboarding.StartForUnfinishedBackup(backupService.addedBackupCardsCount))
store.dispatch(
GlobalAction.Onboarding.StartForUnfinishedBackup(
addedBackupCardsCount = backupService.addedBackupCardsCount,
isSaltPayVisa = backupService.primaryCardIsSaltPayVisa(),
),
)
store.dispatch(NavigationAction.NavigateTo(AppScreen.OnboardingWallet))
}
is BackupAction.DismissBackup -> {
Analytics.send(Onboarding.Backup.Skipped())
if (onboardingWalletState.isSaltPay) throw UnsupportedOperationException()
store.dispatch(BackupAction.FinishBackup)
store.dispatch(BackupAction.FinishBackup())
}
else -> {}
}
@ -395,14 +403,18 @@ private fun finishCardsActivationForDiscardedUnfinishedBackup(cardId: String) {
*/
private fun initSaltPayOnBackupFinishedIfNeeded(
scanResponse: ScanResponse?,
onboardingWalletState: OnboardingWalletState,
state: OnboardingWalletState,
) {
if (onboardingWalletState.isSaltPay && onboardingWalletState.onboardingSaltPayState == null) {
if (scanResponse == null) error("scanning response is null")
if (state.backupState.isInterruptedBackup) return
if (scanResponse == null) return
val (manager, config) = OnboardingSaltPayState.initDependency(scanResponse)
store.dispatchOnMain(OnboardingSaltPayAction.SetDependencies(manager, config))
store.dispatchOnMain(OnboardingSaltPayAction.Update)
if (state.isSaltPay && state.onboardingSaltPayState == null) {
val manager = SaltPayActivationManagerFactory(
blockchain = scanResponse.cardTypesResolver.getBlockchain(),
card = scanResponse.card,
).create()
store.dispatchOnMain(OnboardingSaltPayAction.SetDependencies(manager))
store.dispatchOnMain(OnboardingSaltPayAction.Update(false))
}
}
@ -420,6 +432,7 @@ private fun handleOnBackPressed(state: OnboardingWalletState) {
if (state.isSaltPay) {
showInterruptOnboardingDialog()
} else {
OnboardingHelper.onInterrupted()
store.dispatch(NavigationAction.PopBackTo())
}
}
@ -430,6 +443,7 @@ private fun showInterruptOnboardingDialog() {
store.dispatchDialogShow(
OnboardingDialog.InterruptOnboarding(
onOk = {
OnboardingHelper.onInterrupted()
store.dispatch(BackupAction.DiscardBackup)
store.dispatch(NavigationAction.PopBackTo())
},

View file

@ -38,13 +38,13 @@ private object ReducerForGlobalAction {
fun reduce(action: GlobalAction.Onboarding, state: OnboardingWalletState): OnboardingWalletState {
return when (action) {
is GlobalAction.Onboarding.Start -> {
val isSaltPay = action.scanResponse.cardTypesResolver.isSaltPay()
OnboardingWalletState(
backupState = state.backupState.copy(
maxBackupCards = if (action.scanResponse.cardTypesResolver.isSaltPay()) 1 else 2,
canSkipBackup =
if (action.scanResponse.cardTypesResolver.isSaltPay()) false else action.canSkipBackup,
maxBackupCards = maxBackupCards(isSaltPay),
canSkipBackup = if (isSaltPay) false else action.canSkipBackup,
),
isSaltPay = action.scanResponse.cardTypesResolver.isSaltPay(),
isSaltPay = isSaltPay,
)
}
is GlobalAction.Onboarding.StartForUnfinishedBackup -> {
@ -52,13 +52,16 @@ private object ReducerForGlobalAction {
backupState = state.backupState.copy(
maxBackupCards = action.addedBackupCardsCount,
canSkipBackup = false,
isInterruptedBackup = true,
),
isSaltPay = false,
isSaltPay = action.isSaltPayVisa,
)
}
else -> state
}
}
private fun maxBackupCards(isSaltPay: Boolean): Int = if (isSaltPay) 1 else 2
}
private object BackupReducer {

View file

@ -62,7 +62,7 @@ data class OnboardingWalletState(
-> 9
SaltPayActivationStep.Claim -> 10
SaltPayActivationStep.ClaimInProgress -> 11
SaltPayActivationStep.ClaimSuccess, SaltPayActivationStep.Success, SaltPayActivationStep.Finished,
SaltPayActivationStep.ClaimSuccess, SaltPayActivationStep.Success,
-> getMaxProgress()
}
}
@ -87,6 +87,7 @@ data class BackupState(
val backupStep: BackupStep = BackupStep.InitBackup,
val maxBackupCards: Int = 2,
val canSkipBackup: Boolean = true,
val isInterruptedBackup: Boolean = false,
)
enum class AccessCodeError {

View file

@ -23,6 +23,8 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.math.BigDecimal
import java.math.BigInteger
import java.math.MathContext
import java.math.RoundingMode
import java.util.concurrent.atomic.AtomicLong
/**
@ -33,11 +35,12 @@ class GnosisRegistrator(
) {
private val walletAddress = walletManager.wallet.address
private val blockchain = walletManager.wallet.blockchain
private val token = walletManager.wallet.getFirstToken().guard {
throw NullPointerException("WalletManager for GnosisRegistrator must contain token")
}
private val otpProcessorContractAddress: String = when (walletManager.wallet.blockchain) {
private val otpProcessorContractAddress: String = when (blockchain) {
Blockchain.SaltPay -> "0xc659f4FEd7A84a188F54cBA4A7a49D77c1a20522"
else -> throw IllegalArgumentException("GnosisRegistrator supports only the SaltPay blockchain")
}
@ -47,6 +50,7 @@ class GnosisRegistrator(
private val atomicNonce = AtomicLong()
suspend fun checkHasGas(): Result<Boolean> {
// return Result.Success(true) // test
val wallet = walletManager.safeUpdate().successOr {
return Result.Failure(BlockchainSdkError.WrappedThrowable(it.error))
}
@ -145,6 +149,7 @@ class GnosisRegistrator(
}
suspend fun getAllowance(): Result<Amount> {
// return Result.Success(Amount(BigDecimal(0.1), walletManager.wallet.blockchain)) // test
return walletManager.getAllowance(addressTreasureSafe, token)
}
@ -154,6 +159,13 @@ class GnosisRegistrator(
return Result.Failure(BlockchainSdkError.WrappedThrowable(it.error))
}
}
// return transferFromStandardWay(amountToClaim, signer)
return transferFromHardcodeWay(amountToClaim, signer)
}
@Suppress("UnusedPrivateMember")
private suspend fun transferFromStandardWay(amountToClaim: BigDecimal, signer: TransactionSigner): Result<Unit> {
val amount = Amount(token, amountToClaim)
val feeAmount = walletManager.getFeeToTransferFrom(amount, addressTreasureSafe)
.extractFeeAmount().successOr { return it }
@ -164,6 +176,34 @@ class GnosisRegistrator(
return Result.Success(Unit)
}
private suspend fun transferFromHardcodeWay(amountToClaim: BigDecimal, signer: TransactionSigner): Result<Unit> {
val amount = Amount(token, amountToClaim)
val gasPrice = walletManager.getGasPrice().successOr { return it }
val hardcodeGasLimit = BigInteger("300000")
val hardcodeFeeValue = (hardcodeGasLimit * gasPrice).toBigDecimal(
scale = blockchain.decimals(),
mathContext = MathContext(blockchain.decimals(), RoundingMode.HALF_EVEN),
).movePointLeft(blockchain.decimals())
val hardcodeFeeAmount = Amount(hardcodeFeeValue, blockchain)
val transactionData = walletManager.createTransferFromTransaction(
amount = amount,
fee = hardcodeFeeAmount,
source = addressTreasureSafe,
)
val transactionToSign = walletManager.transactionBuilder.buildTransferFromToSign(
transactionData,
walletManager.txCount.toBigInteger(),
hardcodeGasLimit,
) ?: return Result.Failure(BlockchainSdkError.CustomError("Not enough data"))
return when (val result = walletManager.signAndSend(transactionToSign, signer)) {
SimpleResult.Success -> Result.Success(Unit)
is SimpleResult.Failure -> Result.Failure(result.error)
}
}
@Suppress("MagicNumber")
private fun Result<List<Amount>>.extractFeeAmount(): Result<Amount> {
return when (this) {

View file

@ -1,14 +1,22 @@
@file:Suppress("MaximumLineLength")
package com.tangem.tap.features.onboarding.products.wallet.saltPay
import android.net.Uri
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.Token
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.common.Wallet
import com.tangem.blockchain.common.WalletManagerFactory
import com.tangem.blockchain.extensions.successOr
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.guard
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.isZero
import com.tangem.common.extensions.toHexString
import com.tangem.common.services.Result
@ -23,15 +31,28 @@ import com.tangem.datasource.api.paymentology.models.response.RegistrationRespon
import com.tangem.datasource.api.paymentology.models.response.tryExtractError
import com.tangem.datasource.config.models.KYCProvider
import com.tangem.datasource.config.models.SaltPayConfig
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.SaltPayWorkaround
import com.tangem.domain.common.extensions.successOr
import com.tangem.operations.attestation.AttestWalletKeyResponse
import com.tangem.tap.common.extensions.safeUpdate
import com.tangem.tap.domain.getFirstToken
import com.tangem.tap.domain.model.builders.UserWalletIdBuilder
import com.tangem.tap.features.onboarding.products.wallet.saltPay.message.SaltPayActivationError
import com.tangem.tap.store
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import kotlin.ByteArray
import kotlin.Exception
import kotlin.IllegalStateException
import kotlin.NullPointerException
import kotlin.String
import kotlin.Suppress
import kotlin.Unit
import kotlin.UnsupportedOperationException
import kotlin.byteArrayOf
import kotlin.minus
import com.tangem.blockchain.extensions.Result as BlockchainResult
/**
@ -227,4 +248,97 @@ class KYCUrlProvider(
.appendQueryParameter(kycProvider.externalIdParameterKey, kycRefId)
.build().toString()
}
}
class SaltPayActivationManagerFactory(
private val blockchain: Blockchain,
private val card: CardDTO,
) {
fun create(): SaltPayActivationManager {
val wallet = card.wallets.first()
val saltPayConfig = store.state.globalState.configManager?.config?.saltPayConfig.guard {
throw NullPointerException("SaltPayConfig is not initialized")
}
val wmFactory = store.state.globalState.tapWalletManager.walletManagerFactory
val paymentologyService = store.state.domainNetworks.paymentologyService
// return createDummyActivationManager(saltPayConfig, wmFactory, paymentologyService)
return createActivationPayManager(
cardId = card.cardId,
cardPublicKey = card.cardPublicKey,
kycProvider = saltPayConfig.kycProvider,
gnosisRegistrator = GnosisRegistrator(
walletManager = makeSaltPayWalletManager(
blockchain = blockchain,
walletPublicKey = wallet.publicKey,
wmFactory = wmFactory,
),
),
paymentologyService = paymentologyService,
)
}
private fun makeSaltPayWalletManager(
blockchain: Blockchain,
walletPublicKey: ByteArray,
wmFactory: WalletManagerFactory,
): EthereumWalletManager {
return wmFactory.makeWalletManager(
blockchain = blockchain,
publicKey = Wallet.PublicKey(walletPublicKey, null, null),
tokens = listOf(SaltPayWorkaround.tokenFrom(blockchain)),
) as EthereumWalletManager
}
private fun createActivationPayManager(
cardId: String,
cardPublicKey: ByteArray,
kycProvider: KYCProvider,
gnosisRegistrator: GnosisRegistrator,
paymentologyService: PaymentologyApiService,
): SaltPayActivationManager {
return SaltPayActivationManager(
cardId = cardId,
cardPublicKey = cardPublicKey,
kycProvider = kycProvider,
paymentologyService = paymentologyService,
gnosisRegistrator = gnosisRegistrator,
)
}
@Suppress("UnusedPrivateMember")
private fun createDummyActivationManager(
saltPayConfig: SaltPayConfig,
wmFactory: WalletManagerFactory,
paymentologyService: PaymentologyApiService,
): SaltPayActivationManager {
val fakeSource = DummySaltPayCardSource()
val walletManager = wmFactory.makeWalletManager(
blockchain = fakeSource.blockchain,
publicKey = fakeSource.blockchainPublicKey,
tokens = listOf(fakeSource.token),
) as EthereumWalletManager
return createActivationPayManager(
cardId = fakeSource.cardId,
cardPublicKey = fakeSource.cardPublicKey,
kycProvider = saltPayConfig.kycProvider,
gnosisRegistrator = GnosisRegistrator(walletManager),
paymentologyService = paymentologyService,
)
}
private data class DummySaltPayCardSource(
val blockchain: Blockchain = Blockchain.SaltPay,
val cardId: String = "FF03000001001057",
private val cardPublicKeyString: String = "039C2D2F9B68003766BFC29766761F55F4084E48B8B012BF439E115A79AB122D48",
private val walletPublicKeyString: String = "0399B2DF5B129FBF69097DAA7C44FB5849E578CCFC22228A409870C74B56C4C3D1",
) {
val cardPublicKey: ByteArray = cardPublicKeyString.hexToBytes()
val walletPublicKey: ByteArray = walletPublicKeyString.hexToBytes()
val blockchainPublicKey = Wallet.PublicKey(walletPublicKey, null, null)
val token: Token = SaltPayWorkaround.tokenFrom(blockchain)
}
}

View file

@ -2,6 +2,8 @@ package com.tangem.tap.features.onboarding.products.wallet.saltPay
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.Analytics
import com.tangem.tap.common.analytics.events.Onboarding
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
@ -12,12 +14,20 @@ import com.tangem.tap.store
[REDACTED_AUTHOR]
*/
object SaltPayExceptionHandler {
fun handle(throwable: Throwable) {
when (throwable) {
is SaltPayActivationError -> {
val dialog = when (throwable) {
is SaltPayActivationError.NoGas -> SaltPayDialog.Activation.NoGas
is SaltPayActivationError.NoGas -> {
Analytics.send(Onboarding.NotEnoughGasError())
SaltPayDialog.Activation.NoGas
}
is SaltPayActivationError.PutVisaCard -> SaltPayDialog.Activation.PutVisaCard
is SaltPayActivationError.CardNotPassed -> {
Analytics.send(Onboarding.CardNotPassedError())
SaltPayDialog.Activation.OnError(throwable)
}
else -> SaltPayDialog.Activation.OnError(throwable)
}
store.dispatchDialogShow(dialog)

View file

@ -14,16 +14,20 @@ import com.tangem.wallet.R
[REDACTED_AUTHOR]
*/
object NoFundsForActivationDialog {
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)
setPositiveButton(R.string.chat_button_title) { _, _ ->
val config = store.state.globalState.configManager?.config?.saltPayConfig?.zendesk.guard {
store.dispatchDebugErrorNotification("SaltPayConfig not initialized")
return@setPositiveButton
}
store.dispatch(GlobalAction.OpenChat(SupportInfo(), config))
val sprinklrConfig = store.state.globalState.configManager?.config
?.saltPayConfig
?.sprinklr
.guard {
store.dispatchDebugErrorNotification("SaltPayConfig not initialized")
return@setPositiveButton
}
store.dispatch(GlobalAction.OpenChat(SupportInfo(), sprinklrConfig))
}
setNegativeButton(R.string.common_cancel) { _, _ ->
}

View file

@ -2,7 +2,6 @@ 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.datasource.config.models.SaltPayConfig
import org.rekotlin.Action
import java.math.BigDecimal
@ -12,9 +11,13 @@ import java.math.BigDecimal
sealed class OnboardingSaltPayAction : Action {
object Init : OnboardingSaltPayAction()
/**
* Notification about the switching from the Wallet view to the SaltPay view.
*/
object OnSwitchedToSaltPayProcess : OnboardingSaltPayAction()
data class SetDependencies(
val registrationManager: SaltPayActivationManager,
val saltPayConfig: SaltPayConfig,
) : OnboardingSaltPayAction()
data class SetInProgress(val isInProgress: Boolean) : OnboardingSaltPayAction()
@ -22,7 +25,7 @@ sealed class OnboardingSaltPayAction : Action {
data class SetAccessCode(val accessCode: String?) : OnboardingSaltPayAction()
object Update : OnboardingSaltPayAction()
data class Update(val withAnalytics: Boolean = true) : OnboardingSaltPayAction()
object RegisterCard : OnboardingSaltPayAction()
object OpenUtorgKYC : OnboardingSaltPayAction()
object UtorgKYCRedirectSuccess : OnboardingSaltPayAction()
@ -33,7 +36,11 @@ sealed class OnboardingSaltPayAction : Action {
data class TrySetPin(val pin: String) : OnboardingSaltPayAction()
data class SetPin(val pin: String) : OnboardingSaltPayAction()
data class SetStep(val newStep: SaltPayActivationStep) : OnboardingSaltPayAction()
data class SetStep(
val newStep: SaltPayActivationStep,
val withAnalytics: Boolean = true,
) : OnboardingSaltPayAction()
data class SetAmountToClaim(val amount: Amount?) : OnboardingSaltPayAction()
data class SetTokenBalance(val balanceValue: BigDecimal) : OnboardingSaltPayAction()
}

View file

@ -78,6 +78,9 @@ private fun handleOnboardingSaltPayAction(anyAction: Action, appState: () -> App
onboardingManager.activationStarted(card.cardId)
}
}
is OnboardingSaltPayAction.OnSwitchedToSaltPayProcess -> {
sendAnalyticsScreenOpened(getState().step, newStep = SaltPayActivationStep.None)
}
is OnboardingSaltPayAction.Update -> {
handleInProgress = true
@ -89,7 +92,18 @@ private fun handleOnboardingSaltPayAction(anyAction: Action, appState: () -> App
}
handleInProgress = false
dispatchOnMain(OnboardingSaltPayAction.SetStep(updateStep))
dispatchOnMain(OnboardingSaltPayAction.SetStep(updateStep, action.withAnalytics))
if (updateStep == SaltPayActivationStep.Claim) {
handleClaimRefreshInProgress = true
val tokenAmountValue = state.saltPayManager.getTokenAmount().successOr {
SaltPayExceptionHandler.handle(it.error)
handleClaimRefreshInProgress = false
return@launch
}
dispatchOnMain(OnboardingSaltPayAction.SetTokenBalance(tokenAmountValue))
handleClaimRefreshInProgress = false
}
}
}
is OnboardingSaltPayAction.RegisterCard -> {
@ -136,8 +150,16 @@ private fun handleOnboardingSaltPayAction(anyAction: Action, appState: () -> App
return@launch
}
Analytics.send(Onboarding.PinCodeSet())
var nextStep = SaltPayActivationStep.KycIntro
nextStep = saltPayManager.update(nextStep, state.amountToClaim).successOr {
onException(it.error)
return@launch
}
handleInProgress = false
dispatchOnMain(OnboardingSaltPayAction.SetStep(SaltPayActivationStep.KycIntro))
dispatchOnMain(OnboardingSaltPayAction.SetStep(nextStep))
}
}
is OnboardingSaltPayAction.OpenUtorgKYC -> {
@ -159,13 +181,13 @@ private fun handleOnboardingSaltPayAction(anyAction: Action, appState: () -> App
}
handleInProgress = false
dispatchOnMain(OnboardingSaltPayAction.Update)
dispatchOnMain(OnboardingSaltPayAction.Update())
}
}
is OnboardingSaltPayAction.TrySetPin -> {
try {
assertPinValid(action.pin, getState().pinLength)
Analytics.send(Onboarding.PinCodeSet())
Analytics.send(Onboarding.ButtonSetPinCode())
store.dispatch(OnboardingSaltPayAction.SetPin(action.pin))
store.dispatch(OnboardingSaltPayAction.SetStep(SaltPayActivationStep.CardRegistration))
} catch (error: SaltPayActivationError) {
@ -219,54 +241,48 @@ private fun handleOnboardingSaltPayAction(anyAction: Action, appState: () -> App
val state = getState()
scope.launch {
when (val amountToClaimResult = state.saltPayManager.getAmountToClaim()) {
is Result.Success -> {
// need to re claim
handleClaimRefreshInProgress = false
dispatchOnMain(OnboardingSaltPayAction.SetAmountToClaim(amountToClaimResult.data))
dispatchOnMain(OnboardingSaltPayAction.SetStep(SaltPayActivationStep.Claim))
}
is Result.Failure -> {
when (amountToClaimResult.error) {
is SaltPayActivationError.NoFundsToClaim -> {
val tokenAmountValue = state.saltPayManager.getTokenAmount().successOr {
SaltPayExceptionHandler.handle(it.error)
handleClaimRefreshInProgress = false
return@launch
}
val tokenAmountValue = state.saltPayManager.getTokenAmount().successOr {
SaltPayExceptionHandler.handle(it.error)
handleClaimRefreshInProgress = false
return@launch
}
dispatchOnMain(OnboardingSaltPayAction.SetTokenBalance(tokenAmountValue))
handleClaimRefreshInProgress = false
if (tokenAmountValue.isPositive()) {
dispatchOnMain(OnboardingSaltPayAction.SetTokenBalance(tokenAmountValue))
dispatchOnMain(OnboardingSaltPayAction.SetStep(SaltPayActivationStep.ClaimSuccess))
}
}
else -> {
handleClaimRefreshInProgress = false
SaltPayExceptionHandler.handle(amountToClaimResult.error)
}
}
val amountToClaim = state.saltPayManager.getAmountToClaim().successOr {
if (it.error !is SaltPayActivationError.NoFundsToClaim) {
handleClaimRefreshInProgress = false
SaltPayExceptionHandler.handle(it.error)
return@launch
} else {
null
}
}
handleClaimRefreshInProgress = false
if (tokenAmountValue.isPositive() && amountToClaim == null) {
dispatchOnMain(OnboardingSaltPayAction.SetStep(SaltPayActivationStep.ClaimSuccess))
} else {
// dispatchOnMain(OnboardingSaltPayAction.SetStep(SaltPayActivationStep.ClaimSuccess))
}
}
}
is OnboardingSaltPayAction.SetStep -> {
when (action.newStep) {
SaltPayActivationStep.KycStart -> Analytics.send(Onboarding.KYCStarted())
SaltPayActivationStep.KycWaiting -> Analytics.send(Onboarding.KYCInProgress())
SaltPayActivationStep.KycReject -> Analytics.send(Onboarding.KYCRejected())
SaltPayActivationStep.Claim -> Analytics.send(Onboarding.ClaimScreenOpened())
SaltPayActivationStep.ClaimSuccess, SaltPayActivationStep.Success -> {
Analytics.send(Onboarding.Finished())
val onboardingManager = getOnboardingManager().guard {
// Null is possible if it is started from a standard pending backup and in this case
// it is impossible to get here
store.dispatchDebugErrorNotification("OnboardingManager can't be NULL")
return
}
finishCardActivation(getOnboardingWalletState().backupState, onboardingManager.scanResponse.card)
if (action.withAnalytics) {
sendAnalyticsScreenOpened(getState().step, action.newStep)
}
if (action.newStep == SaltPayActivationStep.ClaimSuccess ||
action.newStep == SaltPayActivationStep.Success
) {
val onboardingManager = getOnboardingManager().guard {
// Null is possible if it is started from a standard pending backup and in this case
// it is impossible to get here
store.dispatchDebugErrorNotification("OnboardingManager can't be NULL")
return
}
else -> {}
finishCardActivation(
getOnboardingWalletState().backupState,
onboardingManager.scanResponse.card,
)
}
}
else -> {
@ -275,6 +291,22 @@ private fun handleOnboardingSaltPayAction(anyAction: Action, appState: () -> App
}
}
private fun sendAnalyticsScreenOpened(currentStep: SaltPayActivationStep, newStep: SaltPayActivationStep) {
if (currentStep == newStep) return
when (newStep) {
SaltPayActivationStep.NeedPin -> Analytics.send(Onboarding.PinScreenOpened())
SaltPayActivationStep.CardRegistration -> Analytics.send(Onboarding.CardConnectionScreenOpened())
SaltPayActivationStep.KycIntro -> Analytics.send(Onboarding.KYCScreenOpened())
SaltPayActivationStep.KycStart -> Analytics.send(Onboarding.KYCStarted())
SaltPayActivationStep.KycWaiting -> Analytics.send(Onboarding.KYCInProgress())
SaltPayActivationStep.KycReject -> Analytics.send(Onboarding.KYCRejected())
SaltPayActivationStep.Claim -> Analytics.send(Onboarding.ClaimScreenOpened())
SaltPayActivationStep.ClaimSuccess, SaltPayActivationStep.Success -> Analytics.send(Onboarding.Finished())
else -> {}
}
}
suspend fun SaltPayActivationManager.update(
currentStep: SaltPayActivationStep,
currentAmountToClaim: Amount?,
@ -362,6 +394,7 @@ private suspend fun getAmountToClaimIfNeeded(
return amountToClaim
}
// return Amount(BigDecimal(0.1), Blockchain.SaltPay)
return when (val result = saltPayManager.getAmountToClaim()) {
is Result.Success -> {
Timber.d("getAmountToClaimIfNeeded: success: %s", result.data)

View file

@ -20,7 +20,6 @@ private fun internalReduce(anyAction: Action, onboardingWalletState: OnboardingW
return onboardingWalletState.copy(
onboardingSaltPayState = OnboardingSaltPayState(
saltPayManager = action.registrationManager,
saltPayConfig = action.saltPayConfig,
),
)
}

View file

@ -2,19 +2,10 @@ package com.tangem.tap.features.onboarding.products.wallet.saltPay.redux
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.WalletManagerFactory
import com.tangem.common.extensions.guard
import com.tangem.datasource.api.paymentology.PaymentologyApiService
import com.tangem.domain.common.SaltPayWorkaround
import com.tangem.domain.common.ScanResponse
import com.tangem.tap.common.toggleWidget.WidgetState
import com.tangem.tap.domain.extensions.makeSaltPayWalletManager
import com.tangem.tap.features.onboarding.products.wallet.saltPay.GnosisRegistrator
import com.tangem.datasource.config.models.KYCProvider
import com.tangem.tap.features.onboarding.products.wallet.saltPay.SaltPayActivationManager
import com.tangem.datasource.config.models.SaltPayConfig
import com.tangem.tap.features.wallet.redux.ProgressState
import com.tangem.tap.store
import java.math.BigDecimal
/**
@ -23,7 +14,6 @@ import java.math.BigDecimal
data class OnboardingSaltPayState(
@Transient
val saltPayManager: SaltPayActivationManager = SaltPayActivationManager.stub(),
val saltPayConfig: SaltPayConfig = SaltPayConfig.stub(),
val pinCode: String? = null,
val accessCode: String? = null,
val amountToClaim: Amount? = null,
@ -38,54 +28,6 @@ data class OnboardingSaltPayState(
get() = if (inProgress) ProgressState.Loading else ProgressState.Done
val pinLength: Int = 4
companion object {
fun initDependency(scanResponse: ScanResponse): Pair<SaltPayActivationManager, SaltPayConfig> {
val globalState = store.state.globalState
val saltPayConfig = globalState.configManager?.config?.saltPayConfig.guard {
throw NullPointerException("SaltPayConfig is not initialized")
}
val gnosisRegistrator = makeGnosisRegistrator(
scanResponse = scanResponse,
wmFactory = globalState.tapWalletManager.walletManagerFactory,
)
val registrationManager = makeSaltPayRegistrationManager(
scanResponse = scanResponse,
gnosisRegistrator = gnosisRegistrator,
paymentologyService = store.state.domainNetworks.paymentologyService,
kycProvider = saltPayConfig.kycProvider,
)
return registrationManager to saltPayConfig
}
fun makeSaltPayRegistrationManager(
scanResponse: ScanResponse,
gnosisRegistrator: GnosisRegistrator,
paymentologyService: PaymentologyApiService,
kycProvider: KYCProvider,
): SaltPayActivationManager {
if (!scanResponse.cardTypesResolver.isSaltPay()) {
throw IllegalArgumentException("Can't initialize the OnboardingSaltPayMiddleware if card is not SalPay")
}
return SaltPayActivationManager(
cardId = scanResponse.card.cardId,
cardPublicKey = scanResponse.card.cardPublicKey,
kycProvider = kycProvider,
paymentologyService = paymentologyService,
gnosisRegistrator = gnosisRegistrator,
)
}
fun makeGnosisRegistrator(
scanResponse: ScanResponse,
wmFactory: WalletManagerFactory,
): GnosisRegistrator {
return GnosisRegistrator(
walletManager = wmFactory.makeSaltPayWalletManager(scanResponse),
)
}
}
}
enum class SaltPayActivationStep {
@ -101,5 +43,4 @@ enum class SaltPayActivationStep {
ClaimInProgress,
ClaimSuccess,
Success,
Finished;
}

View file

@ -80,9 +80,10 @@ internal class OnboardingSaltPayView(
when (state.step) {
OnboardingWalletStep.SaltPay -> setSaltPayStep(state.onboardingSaltPayState)
OnboardingWalletStep.Backup -> {
if (state.backupState.backupStep == BackupStep.Finished) {
if (state.backupState.backupStep == BackupStep.Finished && !state.backupState.isInterruptedBackup) {
// if backup finished -> switch OnboardingWalletStep to the SaltPay
store.dispatch(OnboardingWalletAction.GetToSaltPayStep)
store.dispatch(OnboardingSaltPayAction.OnSwitchedToSaltPayProcess)
} else {
// if not -> back to the standard backup process
walletFragment.handleOnboardingStep(state)
@ -103,7 +104,7 @@ internal class OnboardingSaltPayView(
val uri = Uri.parse(state.onboardingSaltPayState.saltPayCardArtworkUrl)
walletFragment.loadImageIntoImageView(uri, imvFrontCard)
}
imvFirstBackupCard.load(R.drawable.img_salt_pay_visa)
imvFirstBackupCard.load(R.drawable.card_placeholder_wallet)
// if (state.onboardingSaltPayState?.saltPayCardArtworkUrl == null) {
// //if saltPay url not loaded -> load from resource
@ -233,7 +234,7 @@ internal class OnboardingSaltPayView(
btnKycAction.text = getText(R.string.onboarding_button_kyc_waiting)
btnKycAction.setOnClickListener {
store.dispatch(OnboardingSaltPayAction.Update)
store.dispatch(OnboardingSaltPayAction.Update())
}
progressButton = SaltPayProgressButton(root)
}
@ -249,7 +250,7 @@ internal class OnboardingSaltPayView(
btnOpenSupportChat.hide()
btnKycAction.text = getText(R.string.onboarding_button_kyc_start)
btnKycAction.setOnClickListener {
store.dispatch(OnboardingSaltPayAction.Update)
store.dispatch(OnboardingSaltPayAction.Update())
}
progressButton = SaltPayProgressButton(root)
}

View file

@ -0,0 +1,10 @@
package com.tangem.tap.features.sprinklr.redux
import com.tangem.datasource.config.models.SprinklrConfig
import org.rekotlin.Action
sealed interface SprinklrAction : Action {
data class Init(val userId: String, val config: SprinklrConfig) : SprinklrAction
data class UpdateUrl(val url: String) : SprinklrAction
data class UpdateSprinklrDomains(val domains: List<String>) : SprinklrAction
}

View file

@ -0,0 +1,42 @@
package com.tangem.tap.features.sprinklr.redux
import com.tangem.datasource.config.models.SprinklrConfig
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.sprinklr.redux.model.SprinklrUrl
import com.tangem.tap.store
import org.rekotlin.Middleware
internal class SprinklrMiddleware {
val middleware: Middleware<AppState> = { _, appStateProvider ->
{ next ->
{ action ->
val appState = appStateProvider()
if (action is SprinklrAction && appState != null) {
handleAction(action)
}
next(action)
}
}
}
private fun handleAction(action: SprinklrAction) {
when (action) {
is SprinklrAction.Init -> updateUrl(action.userId, action.config)
is SprinklrAction.UpdateUrl,
is SprinklrAction.UpdateSprinklrDomains,
-> Unit
}
}
private fun updateUrl(userId: String, config: SprinklrConfig) {
updateSprinklrDomains(config.baseUrl)
val url = SprinklrUrl.Prod(userId, config.baseUrl, config.appId).url
store.dispatchOnMain(SprinklrAction.UpdateUrl(url))
}
private fun updateSprinklrDomains(baseUrl: String) {
val domains = listOf(baseUrl, SprinklrUrl.Static.url)
store.dispatchOnMain(SprinklrAction.UpdateSprinklrDomains(domains))
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.tap.features.sprinklr.redux
import com.tangem.tap.common.redux.AppState
import org.rekotlin.Action
internal object SprinklrReducer {
fun reduce(action: Action, state: AppState): SprinklrState {
return if (action is SprinklrAction) {
internalReduce(action, state.sprinklrState)
} else state.sprinklrState
}
private fun internalReduce(action: SprinklrAction, state: SprinklrState): SprinklrState {
return when (action) {
is SprinklrAction.Init -> state
is SprinklrAction.UpdateUrl -> state.copy(url = action.url)
is SprinklrAction.UpdateSprinklrDomains -> state.copy(sprinklrDomains = action.domains)
}
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.tap.features.sprinklr.redux
data class SprinklrState(
val url: String = "",
val sprinklrDomains: List<String> = emptyList(),
)

View file

@ -0,0 +1,27 @@
package com.tangem.tap.features.sprinklr.redux.model
internal sealed class SprinklrUrl {
abstract val url: String
class Prod(userId: String, baseUrl: String, appId: String) : SprinklrUrl() {
private val locale = java.util.Locale.getDefault().language
override val url: String = "$baseUrl/page" +
"?appId=$appId" +
"&device=$Device" +
"&enableClose=$CloseEnabled" +
"&zoom=$ZoomEnabled" +
"&locale=$locale" +
"&user_id=$userId"
companion object {
private const val Device = "MOBILE"
private const val CloseEnabled = true
private const val ZoomEnabled = false
}
}
object Static : SprinklrUrl() {
override val url: String = "live-chat-static.sprinklr.com"
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.tap.features.sprinklr.ui
import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.activity.viewModels
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import com.tangem.core.analytics.Analytics
import com.tangem.core.ui.fragments.ComposeActivity
import com.tangem.tap.common.analytics.events.Chat
internal class SprinklrActivity : ComposeActivity<SprinklrScreenState>() {
private val viewModel by viewModels<SprinklrViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Analytics.send(Chat.ScreenOpened())
viewModel.setNavigateBackCallback {
this.finish()
}
}
@Composable
override fun provideState(): State<SprinklrScreenState> {
return viewModel.state.collectAsState()
}
@Composable
override fun ScreenContent(
state: SprinklrScreenState,
modifier: Modifier,
) {
BackHandler(onBack = state.onNavigateBack)
SprinklrScreenContent(
modifier = modifier
.systemBarsPadding()
.imePadding(),
state = state,
)
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.tap.features.sprinklr.ui
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.google.accompanist.web.WebView
import com.google.accompanist.web.rememberWebViewState
import com.tangem.tap.features.sprinklr.ui.webview.SprinklrWebViewClient
@SuppressLint("SetJavaScriptEnabled")
@Composable
internal fun SprinklrScreenContent(
state: SprinklrScreenState,
modifier: Modifier = Modifier,
) {
WebView(
modifier = modifier,
state = rememberWebViewState(url = state.initialUrl),
onCreated = { webView ->
with(webView.settings) {
javaScriptEnabled = true
domStorageEnabled = true
}
},
client = remember { SprinklrWebViewClient(state.onNewUrl) },
)
}

View file

@ -0,0 +1,10 @@
package com.tangem.tap.features.sprinklr.ui
import com.google.accompanist.web.WebContent
internal data class SprinklrScreenState(
val initialUrl: String = "",
val sprinklrDomains: List<String> = emptyList(),
val onNavigateBack: () -> Unit = {},
val onNewUrl: WebContent.(String) -> WebContent = { this },
)

View file

@ -0,0 +1,64 @@
package com.tangem.tap.features.sprinklr.ui
import androidx.lifecycle.ViewModel
import com.google.accompanist.web.WebContent
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.features.sprinklr.redux.SprinklrState
import com.tangem.tap.store
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.rekotlin.StoreSubscriber
internal class SprinklrViewModel : ViewModel(), StoreSubscriber<SprinklrState> {
private val stateInternal = MutableStateFlow(SprinklrScreenState())
val state = stateInternal.asStateFlow()
init {
subscribeToStoreChanges()
}
override fun newState(state: SprinklrState) {
stateInternal.update { prevState ->
prevState.copy(
initialUrl = state.url,
sprinklrDomains = state.sprinklrDomains,
onNewUrl = { updateWebContentOrOpenExternalUrl(it) },
)
}
}
override fun onCleared() {
store.unsubscribe(this)
}
fun setNavigateBackCallback(callback: () -> Unit) {
stateInternal.update { prevState ->
prevState.copy(
onNavigateBack = callback,
)
}
}
private fun subscribeToStoreChanges() {
store.subscribe(this) { appState ->
appState.skip { old, new -> old.sprinklrState == new.sprinklrState }
.select { it.sprinklrState }
}
}
private fun WebContent.updateWebContentOrOpenExternalUrl(url: String): WebContent {
return if (isExternalUrl(url)) {
store.dispatchOnMain(NavigationAction.OpenUrl(url))
this
} else when (this) {
is WebContent.Url -> copy(url = url)
else -> WebContent.Url(url)
}
}
private fun isExternalUrl(url: String): Boolean {
return state.value.sprinklrDomains.none { url.contains(it) }
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.tap.features.sprinklr.ui.webview
import android.webkit.WebResourceRequest
import android.webkit.WebView
import com.google.accompanist.web.AccompanistWebViewClient
import com.google.accompanist.web.WebContent
internal class SprinklrWebViewClient(
private val onNewUrl: WebContent.(String) -> WebContent,
) : AccompanistWebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
// If the url hasn't changed, this is probably an internal event like
// a javascript reload. We should let it happen.
if (view?.url == request?.url.toString()) {
return false
}
request?.let {
state.content = onNewUrl(state.content, it.url.toString())
}
return true
}
}

View file

@ -57,6 +57,12 @@ import com.tangem.tap.features.tokens.addCustomToken.compose.test.TestCasesList
import com.tangem.wallet.R
import kotlinx.coroutines.launch
/**
[REDACTED_AUTHOR]
*/
@Suppress("UnusedPrivateClass")
private class AddCustomTokenScreen // for simple search
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun AddCustomTokenScreen(

View file

@ -1,5 +1,6 @@
package com.tangem.tap.features.wallet.redux
import com.tangem.blockchain.common.TransactionData
import com.tangem.common.extensions.isZero
import com.tangem.domain.common.extensions.toNetworkId
import com.tangem.feature.swap.domain.SwapInteractor
@ -14,6 +15,7 @@ import java.math.BigDecimal
data class WalletData(
val pendingTransactions: List<PendingTransaction> = emptyList(),
val historyTransactions: List<TransactionData>? = null,
val hashesCountVerified: Boolean? = null,
val walletAddresses: WalletAddresses? = null,
val currencyData: BalanceWidgetData = BalanceWidgetData(),

View file

@ -81,7 +81,7 @@ data class WalletState(
walletsStores[0]
}
private val primaryWalletManager: WalletManager?
val primaryWalletManager: WalletManager?
get() = primaryWalletStore?.walletManager
val primaryWalletData: WalletData?
@ -317,6 +317,7 @@ data class Artwork(
const val MARTA_CARD_ID = "BC02"
const val TWIN_CARD_1 = "https://app.tangem.com/cards/card_tg085.png"
const val TWIN_CARD_2 = "https://app.tangem.com/cards/card_tg086.png"
const val SALT_PAY_URL = "key_for_switch_url_to_drawableId_of_salt_pay_card"
}
}

View file

@ -117,6 +117,7 @@ private fun WalletDataModel.mapToReduxModel(
?.toString(),
errorMessage = status.errorMessage,
),
historyTransactions = historyTransactions,
)
}

View file

@ -9,6 +9,7 @@ import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Token.ButtonRemoveToken
import com.tangem.tap.common.extensions.addContext
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.extensions.dispatchErrorNotification
import com.tangem.tap.common.extensions.dispatchOnMain
@ -169,6 +170,7 @@ class MultiWalletMiddleware {
is WalletAction.MultiWallet.ShowWalletBackupWarning -> Unit
is WalletAction.MultiWallet.BackupWallet -> {
store.state.globalState.scanResponse?.let {
Analytics.addContext(it)
store.dispatch(GlobalAction.Onboarding.Start(it, canSkipBackup = false))
store.dispatch(NavigationAction.NavigateTo(AppScreen.OnboardingWallet))
}

View file

@ -3,6 +3,7 @@ package com.tangem.tap.features.wallet.redux.middlewares
import androidx.core.os.bundleOf
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.AmountType
import com.tangem.common.extensions.guard
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.extensions.toCoinId
import com.tangem.domain.common.extensions.toNetworkId
@ -22,6 +23,7 @@ import com.tangem.tap.features.send.redux.PrepareSendScreen
import com.tangem.tap.features.send.redux.SendAction
import com.tangem.tap.features.wallet.models.Currency
import com.tangem.tap.features.wallet.redux.WalletAction
import com.tangem.tap.features.wallet.redux.WalletState
import com.tangem.tap.network.exchangeServices.CurrencyExchangeManager
import com.tangem.tap.network.exchangeServices.buyErc20TestnetTokens
import com.tangem.tap.scope
@ -49,10 +51,9 @@ class TradeCryptoMiddleware {
action: WalletAction.TradeCryptoAction.Buy,
) {
val selectedWalletData = store.state.walletState.selectedWalletData ?: return
val currency = chooseAppropriateCurrency(store.state.walletState) ?: return
val currency = selectedWalletData.currency
Analytics.send(Token.ButtonBuy(AnalyticsParam.CurrencyType.Currency(currency)))
if (action.checkUserLocation && state()?.globalState?.userCountryCode == RUSSIA_COUNTRY_CODE) {
store.dispatchOnMain(WalletAction.DialogAction.RussianCardholdersWarningDialog())
return
@ -96,12 +97,12 @@ class TradeCryptoMiddleware {
private fun proceedSellAction() {
val selectedWalletData = store.state.walletState.selectedWalletData ?: return
val currency = chooseAppropriateCurrency(store.state.walletState) ?: return
val appCurrency = store.state.globalState.appCurrency
val addresses = selectedWalletData.walletAddresses?.list.orEmpty()
if (addresses.isEmpty()) return
val currency = selectedWalletData.currency
Analytics.send(Token.ButtonSell(AnalyticsParam.CurrencyType.Currency(currency)))
store.state.globalState.exchangeManager.getUrl(
@ -116,6 +117,17 @@ class TradeCryptoMiddleware {
}
}
private fun chooseAppropriateCurrency(walletState: WalletState): Currency? {
return if (walletState.primaryTokenData == null) {
walletState.selectedWalletData?.currency
} else {
walletState.primaryTokenData?.currency as? Currency.Token
}.guard {
store.dispatchDebugErrorNotification("Can't select an appropriate currency for a Trade action")
return null
}
}
private fun preconfigureAndOpenSendScreen(action: WalletAction.TradeCryptoAction.SendCrypto) {
val selectedWalletData = store.state.walletState.selectedWalletData ?: return

View file

@ -15,6 +15,7 @@ import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.tap.common.analytics.converters.BasicEventsPreChecker
import com.tangem.tap.common.analytics.converters.BasicEventsSourceData
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Basic
import com.tangem.tap.common.analytics.events.MainScreen
import com.tangem.tap.common.analytics.events.Token
import com.tangem.tap.common.extensions.copyToClipboard
@ -249,6 +250,8 @@ class WalletMiddleware {
}
is NetworkStateChanged -> {
store.dispatch(WalletAction.Warnings.CheckHashesCount.CheckHashesCountOnline)
if (!action.isOnline) return
val selectedUserWallet = userWalletsListManagerSafe?.selectedUserWalletSync
if (selectedUserWallet != null) {
scope.launch { globalState.tapWalletManager.loadData(selectedUserWallet) }
@ -380,7 +383,7 @@ class WalletMiddleware {
}
else -> {
Analytics.send(MainScreen.ButtonScanCard())
store.dispatch(WalletAction.Scan(MainScreen.CardWasScanned()))
store.dispatch(WalletAction.Scan(Basic.CardWasScanned(AnalyticsParam.ScannedFrom.Main)))
}
}
}

View file

@ -6,6 +6,8 @@ import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.WalletManager
import com.tangem.tap.common.extensions.dispatchToastNotification
import com.tangem.tap.common.extensions.getBlockchainTxHistory
import com.tangem.tap.common.extensions.getTokenTxHistory
import com.tangem.tap.common.extensions.toFiatString
import com.tangem.tap.common.extensions.toFormattedCurrencyString
import com.tangem.tap.common.redux.navigation.AppScreen
@ -54,6 +56,7 @@ class MultiWalletReducer {
blockchain.derivationPath,
),
existentialDepositString = getExistentialDeposit(walletManager),
historyTransactions = walletManager?.getBlockchainTxHistory(),
)
WalletStore(
@ -89,6 +92,7 @@ class MultiWalletReducer {
action.blockchain.derivationPath,
),
existentialDepositString = getExistentialDeposit(walletManager),
historyTransactions = walletManager?.getBlockchainTxHistory(),
)
val walletStore = WalletStore(
walletManager = walletManager,
@ -153,6 +157,7 @@ class MultiWalletReducer {
derivationPath = action.blockchain.derivationPath,
),
walletRent = findWalletRent(state.getWalletStore(walletManager.wallet)),
historyTransactions = walletManager.getTokenTxHistory(action.token),
)
state.updateWalletData(newTokenWalletData)
}
@ -206,8 +211,8 @@ fun Token.toWallet(state: WalletState, blockchain: BlockchainNetwork): WalletDat
val currency = Currency.fromBlockchainNetwork(blockchain, this)
if (state.currencies.contains(currency)) return null
val walletManager = state.getWalletManager(currency)?.wallet
val walletAddresses = createAddressList(walletManager)
val walletManager = state.getWalletManager(currency)
val walletAddresses = createAddressList(walletManager?.wallet)
return WalletData(
currencyData = BalanceWidgetData(
@ -218,5 +223,6 @@ fun Token.toWallet(state: WalletState, blockchain: BlockchainNetwork): WalletDat
walletAddresses = walletAddresses,
mainButton = WalletMainButton.SendButton(false),
currency = currency,
historyTransactions = walletManager?.getTokenTxHistory(this),
)
}

View file

@ -5,7 +5,9 @@ import com.tangem.tap.features.wallet.redux.WalletData
import com.tangem.tap.features.wallet.ui.BalanceStatus
import java.math.BigDecimal
fun List<WalletData>.findProgressState(): ProgressState {
fun List<WalletData>.findProgressState(initialState: ProgressState = ProgressState.Done): ProgressState {
if (this.isEmpty()) return initialState
return this
.mapToProgressState()
.reduce(ProgressState::or)

View file

@ -348,6 +348,7 @@ private fun internalReduce(action: Action, state: AppState, appStateHolder: AppS
}
is WalletAction.WalletStoresChanged.UpdateWalletStores -> {
newState = newState.copy(
state = action.reduxWalletStores.flatMap { it.walletsData }.findProgressState(newState.state),
walletsStores = action.reduxWalletStores,
)
}

View file

@ -20,7 +20,6 @@ import coil.size.Scale
import com.tangem.core.analytics.Analytics
import com.tangem.core.ui.fragments.setStatusBarColor
import com.tangem.core.ui.utils.OneTouchClickListener
import com.tangem.domain.common.TapWorkarounds.isSaltPay
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.tap.MainActivity
import com.tangem.tap.common.analytics.events.MainScreen
@ -40,9 +39,9 @@ import com.tangem.tap.features.wallet.redux.WalletAction
import com.tangem.tap.features.wallet.redux.WalletState
import com.tangem.tap.features.wallet.ui.adapters.WarningMessagesAdapter
import com.tangem.tap.features.wallet.ui.wallet.MultiWalletView
import com.tangem.tap.features.wallet.ui.wallet.SaltPayWalletView
import com.tangem.tap.features.wallet.ui.wallet.SingleWalletView
import com.tangem.tap.features.wallet.ui.wallet.WalletView
import com.tangem.tap.features.wallet.ui.wallet.saltPay.SaltPayWalletView
import com.tangem.tap.store
import com.tangem.tap.userWalletsListManager
import com.tangem.wallet.BuildConfig
@ -155,19 +154,22 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
override fun newState(state: WalletState) {
if (activity == null || view == null) return
val isSaltPay = store.state.globalState.scanResponse?.card?.isSaltPay == true
val isSaltPay = store.state.globalState.scanResponse?.cardTypesResolver?.isSaltPay() == true
when {
isSaltPay && walletView !is SaltPayWalletView -> {
walletView.onViewDestroy()
walletView = SaltPayWalletView()
walletView.changeWalletView(this, binding)
}
state.isMultiwalletAllowed && state.primaryWalletData?.currencyData?.status != BalanceStatus.EmptyCard &&
walletView !is MultiWalletView -> {
walletView.onViewDestroy()
walletView = MultiWalletView()
walletView.changeWalletView(this, binding)
}
!state.isMultiwalletAllowed && !isSaltPay && walletView !is SingleWalletView -> {
walletView.onViewDestroy()
walletView = SingleWalletView()
walletView.changeWalletView(this, binding)
}
@ -185,7 +187,7 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
}
setupNoInternetHandling(state)
setupCardImage(state)
setupCardImage(state, isSaltPay)
if (!isSaltPay) showWarningsIfPresent(state.mainWarningsList)
@ -222,9 +224,9 @@ class WalletFragment : Fragment(R.layout.fragment_wallet), StoreSubscriber<Walle
}
}
private fun setupCardImage(state: WalletState) {
private fun setupCardImage(state: WalletState, isSaltPay: Boolean) {
// TODO: SaltPay: remove hardCode
if (store.state.globalState.scanResponse?.cardTypesResolver?.isSaltPay() == true) {
if (isSaltPay) {
binding.ivCard.load(R.drawable.img_salt_pay_visa) {
scale(Scale.FIT)
crossfade(enable = true)

View file

@ -1,88 +0,0 @@
package com.tangem.tap.features.wallet.ui.wallet
import com.tangem.domain.common.extensions.debounce
import com.tangem.tap.common.extensions.animateVisibility
import com.tangem.tap.common.extensions.formatAmountAsSpannedString
import com.tangem.tap.common.extensions.hide
import com.tangem.tap.common.extensions.show
import com.tangem.tap.features.wallet.redux.ProgressState
import com.tangem.tap.features.wallet.redux.WalletAction
import com.tangem.tap.features.wallet.redux.WalletState
import com.tangem.tap.features.wallet.ui.WalletFragment
import com.tangem.tap.mainScope
import com.tangem.tap.store
import com.tangem.wallet.databinding.FragmentWalletBinding
import com.tangem.wallet.databinding.LayoutSaltPayWalletBinding
import org.rekotlin.Action
class SaltPayWalletView : WalletView() {
private lateinit var saltPayBinding: LayoutSaltPayWalletBinding
private val actionDebouncer = debounce<Action>(500, mainScope) { store.dispatch(it) }
override fun changeWalletView(fragment: WalletFragment, binding: FragmentWalletBinding) {
saltPayBinding = binding.lSaltPayWallet
setFragment(fragment, binding)
onViewCreated()
showSaltPayView(binding)
}
private fun showSaltPayView(binding: FragmentWalletBinding) = with(binding) {
rvWarningMessages.hide()
rvPendingTransaction.hide()
rvMultiwallet.hide()
tvTwinCardNumber.hide()
lCardBalance.root.hide()
lSingleWalletBalance.root.hide()
lAddress.root.hide()
rowButtons.hide()
btnAddToken.hide()
pbLoadingUserTokens.hide()
lSaltPayWallet.root.show()
}
override fun onViewCreated() {
}
override fun onNewState(state: WalletState) {
setupBalanceWidget(state)
}
private fun setupBalanceWidget(state: WalletState) = with(saltPayBinding.lSaltPayBalance) {
val tokenData = state.primaryTokenData ?: return@with
val appCurrency = store.state.globalState.appCurrency
val mainProgressState = state.state
if (mainProgressState == ProgressState.Loading) {
veilBalance.veil()
veilBalanceCrypto.veil()
} else {
veilBalanceCrypto.unVeil()
}
tvProcessing.animateVisibility(show = mainProgressState == ProgressState.Error)
veilBalanceCrypto.animateVisibility(show = mainProgressState != ProgressState.Error)
if (tokenData.currencyData.fiatAmount == null) {
actionDebouncer(WalletAction.LoadFiatRate())
} else {
veilBalance.unVeil()
tvBalance.text = tokenData.currencyData.fiatAmount.formatAmountAsSpannedString(
currencySymbol = appCurrency.symbol,
)
}
tvBalanceCrypto.text = tokenData.currencyData.amountFormatted
tvCurrencyName.text = appCurrency.code
tvCurrencyName.setOnClickListener {
store.dispatch(WalletAction.AppCurrencyAction.ChooseAppCurrency)
}
btnBuy.show(tokenData.isAvailableToBuy(store.state.globalState.exchangeManager))
btnBuy.setOnClickListener {
store.dispatch(WalletAction.TradeCryptoAction.Buy(false))
}
}
}

View file

@ -6,10 +6,12 @@ import com.tangem.tap.features.wallet.ui.WalletFragment
import com.tangem.wallet.databinding.FragmentWalletBinding
abstract class WalletView {
var swapInteractor: SwapInteractor? = null
protected var fragment: WalletFragment? = null
protected var binding: FragmentWalletBinding? = null
fun setFragment(fragment: WalletFragment, binding: FragmentWalletBinding) {
this.fragment = fragment
this.binding = binding
@ -20,6 +22,10 @@ abstract class WalletView {
binding = null
}
open fun onViewDestroy() {
removeFragment()
}
open fun onDestroyFragment() {}
abstract fun changeWalletView(fragment: WalletFragment, binding: FragmentWalletBinding)

View file

@ -0,0 +1,21 @@
package com.tangem.tap.features.wallet.ui.wallet.saltPay
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionStatus
import com.tangem.tap.features.wallet.models.PendingTransactionType
/**
[REDACTED_AUTHOR]
*/
data class HistoryTransactionData(
val transactionData: TransactionData,
private val walletAddress: String,
) {
fun isInProgress(): Boolean = transactionData.status == TransactionStatus.Unconfirmed
fun getTransactionType(): PendingTransactionType = when {
transactionData.sourceAddress.lowercase() == walletAddress.lowercase() -> PendingTransactionType.Outgoing
transactionData.destinationAddress.lowercase() == walletAddress.lowercase() -> PendingTransactionType.Incoming
else -> PendingTransactionType.Unknown
}
}

View file

@ -0,0 +1,254 @@
package com.tangem.tap.features.wallet.ui.wallet.saltPay
import android.view.LayoutInflater
import android.view.ViewGroup
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.extensions.debounce
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.tap.common.ShimmerData
import com.tangem.tap.common.ShimmerRecyclerAdapter
import com.tangem.tap.common.analytics.events.MainScreen
import com.tangem.tap.common.extensions.animateVisibility
import com.tangem.tap.common.extensions.formatAmountAsSpannedString
import com.tangem.tap.common.extensions.hide
import com.tangem.tap.common.extensions.show
import com.tangem.tap.common.recyclerView.SpaceItemDecoration
import com.tangem.tap.features.wallet.redux.ProgressState
import com.tangem.tap.features.wallet.redux.WalletAction
import com.tangem.tap.features.wallet.redux.WalletState
import com.tangem.tap.features.wallet.ui.WalletFragment
import com.tangem.tap.features.wallet.ui.wallet.WalletView
import com.tangem.tap.features.wallet.ui.wallet.saltPay.rv.HistoryItemData
import com.tangem.tap.features.wallet.ui.wallet.saltPay.rv.HistoryTransactionData
import com.tangem.tap.features.wallet.ui.wallet.saltPay.rv.TxHistoryAdapter
import com.tangem.tap.mainScope
import com.tangem.tap.network.exchangeServices.CurrencyExchangeManager
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.wallet.databinding.FragmentWalletBinding
import com.tangem.wallet.databinding.ItemSaltPayTxHistoryShimmerBinding
import com.tangem.wallet.databinding.LayoutSaltPayBalanceBinding
import com.tangem.wallet.databinding.LayoutSaltPayTxHistoryBinding
import com.tangem.wallet.databinding.LayoutSaltPayWalletBinding
import kotlinx.coroutines.launch
import org.rekotlin.Action
import timber.log.Timber
/**
[REDACTED_AUTHOR]
*/
class SaltPayWalletView : WalletView() {
private var saltPayBinding: LayoutSaltPayWalletBinding? = null
private val actionDebouncer = debounce<Action>(500, mainScope) { store.dispatch(it) }
private val balanceWidget: LayoutSaltPayBalanceBinding?
get() = saltPayBinding?.lSaltPayBalance
private val txWidget: LayoutSaltPayTxHistoryBinding?
get() = saltPayBinding?.lSaltPayTxHistory
private val itemDecorator = SpaceItemDecoration.vertical(10F)
override fun changeWalletView(fragment: WalletFragment, binding: FragmentWalletBinding) {
Timber.d("changeWalletView")
saltPayBinding = binding.lSaltPayWallet
showSaltPayView(binding)
onViewCreated()
}
override fun onViewCreated() {
Timber.d("onViewCreated")
prepareTxRecyclers(txWidget!!)
}
private fun showSaltPayView(binding: FragmentWalletBinding) = with(binding) {
Timber.d("showSaltPayView")
rvWarningMessages.hide()
rvPendingTransaction.hide()
rvMultiwallet.hide()
tvTwinCardNumber.hide()
lCardBalance.root.hide()
lSingleWalletBalance.root.hide()
lAddress.root.hide()
rowButtons.hide()
btnAddToken.hide()
pbLoadingUserTokens.hide()
lSaltPayWallet.root.show()
}
private fun prepareTxRecyclers(txWidget: LayoutSaltPayTxHistoryBinding) = with(txWidget) {
Timber.d("prepareTxRecyclers")
val vhViewFactory: (ViewGroup) -> ViewGroup = {
val inflater = LayoutInflater.from(it.context)
ItemSaltPayTxHistoryShimmerBinding.inflate(inflater, it, false).root
}
val adapter = ShimmerRecyclerAdapter(vhViewFactory)
rvTxHistoryShimmer.adapter = adapter
rvTxHistoryShimmer.addItemDecoration(itemDecorator)
adapter.submitList(
listOf(
ShimmerData(),
ShimmerData(),
ShimmerData(),
),
)
rvTxHistory.adapter = TxHistoryAdapter()
rvTxHistory.addItemDecoration(itemDecorator)
}
override fun onNewState(state: WalletState) {
val balanceWidget = balanceWidget ?: return
val txWidget = txWidget ?: return
val exchangeManager = store.state.globalState.exchangeManager
setupBalanceWidget(balanceWidget, exchangeManager, state)
setupTxHistoryWidget(txWidget, state)
}
private fun setupBalanceWidget(
balanceWidget: LayoutSaltPayBalanceBinding,
exchangeManager: CurrencyExchangeManager,
state: WalletState,
) = with(balanceWidget) {
Timber.d("setupBalanceWidget")
val tokenData = state.primaryTokenData ?: return
val appCurrency = store.state.globalState.appCurrency
val mainProgressState = state.state
if (mainProgressState == ProgressState.Loading) {
veilBalance.veil()
veilBalanceCrypto.veil()
} else {
veilBalanceCrypto.unVeil()
}
tvUnreachable.animateVisibility(show = mainProgressState == ProgressState.Error)
veilBalanceCrypto.animateVisibility(show = mainProgressState != ProgressState.Error)
if (tokenData.currencyData.fiatAmount == null) {
veilBalance.veil()
actionDebouncer(WalletAction.LoadFiatRate())
} else {
veilBalance.unVeil()
tvBalance.text = tokenData.currencyData.fiatAmount.formatAmountAsSpannedString(
currencySymbol = appCurrency.symbol,
)
}
tvBalanceCrypto.text = tokenData.currencyData.amountFormatted
tvCurrencyName.text = appCurrency.code
tvCurrencyName.setOnClickListener {
store.dispatch(WalletAction.AppCurrencyAction.ChooseAppCurrency)
}
btnBuy.show(tokenData.isAvailableToBuy(exchangeManager))
btnBuy.setOnClickListener {
Analytics.send(MainScreen.ButtonBuy())
store.dispatch(WalletAction.TradeCryptoAction.Buy(false))
}
}
private fun setupTxHistoryWidget(txWidget: LayoutSaltPayTxHistoryBinding, state: WalletState) {
Timber.d("setupTxHistoryWidget")
if (state.state == ProgressState.Loading) {
handleTxLoading(txWidget)
return
}
if (state.state == ProgressState.Error) {
handleTxError(txWidget)
return
}
if (state.state != ProgressState.Done) return
val walletAddress = state.primaryWalletManager?.wallet?.address ?: return
val tokenData = state.primaryTokenData ?: return
val historyTransactions = tokenData.historyTransactions ?: return
if (historyTransactions.isEmpty()) {
handleTxEmpty(txWidget)
return
}
scope.launch {
val dateAssociatedHistory = mutableListOf<Pair<String, MutableList<HistoryTransactionData>>>()
historyTransactions.forEach { tx ->
val txHistoryData = HistoryTransactionData(tx, walletAddress)
val item = dateAssociatedHistory.firstOrNull { it.first == txHistoryData.date }
if (item == null) {
dateAssociatedHistory.add(Pair(txHistoryData.date, mutableListOf(txHistoryData)))
} else {
item.second.add(txHistoryData)
}
}
val rvDataList = mutableListOf<HistoryItemData>()
dateAssociatedHistory.forEach { (txData, txHistoryData) ->
rvDataList.add(HistoryItemData.Date(txData))
rvDataList.addAll(txHistoryData.map { HistoryItemData.TransactionData(it) })
}
withMainContext { handleTxSuccess(txWidget, rvDataList) }
}
}
private fun handleTxLoading(txWidget: LayoutSaltPayTxHistoryBinding) = with(txWidget) {
Timber.d("handleTxLoading")
groupEmpty.hide()
groupError.hide()
groupSuccess.hide()
// root.beginDelayedTransition()
groupShimmer.show()
}
private fun handleTxSuccess(
txWidget: LayoutSaltPayTxHistoryBinding,
dataList: List<HistoryItemData>,
) = with(txWidget) {
Timber.d("handleTxSuccess")
groupShimmer.hide()
groupEmpty.hide()
groupError.hide()
// root.beginDelayedTransition()
updateTxHistoryWidget(txWidget, dataList)
groupSuccess.show()
}
private fun handleTxEmpty(txWidget: LayoutSaltPayTxHistoryBinding) = with(txWidget) {
Timber.d("handleTxEmpty")
groupShimmer.hide()
groupError.hide()
groupSuccess.hide()
// root.beginDelayedTransition()
updateTxHistoryWidget(txWidget, emptyList())
groupEmpty.show()
}
private fun handleTxError(txWidget: LayoutSaltPayTxHistoryBinding) = with(txWidget) {
Timber.d("handleTxError")
groupShimmer.hide()
groupEmpty.hide()
groupSuccess.hide()
// root.beginDelayedTransition()
updateTxHistoryWidget(txWidget, emptyList())
groupError.show()
}
private fun updateTxHistoryWidget(
txWidget: LayoutSaltPayTxHistoryBinding,
dataList: List<HistoryItemData>,
) = with(txWidget) {
(rvTxHistory.adapter as TxHistoryAdapter).submitList(dataList)
}
override fun onViewDestroy() {
Timber.d("onViewDestroy")
txWidget?.apply {
rvTxHistoryShimmer.removeItemDecoration(itemDecorator)
rvTxHistory.removeItemDecoration(itemDecorator)
}
super.onViewDestroy()
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.tap.features.wallet.ui.wallet.saltPay.rv
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionStatus
import com.tangem.common.extensions.guard
import com.tangem.tap.common.extensions.toFormattedCurrencyString
import com.tangem.tap.features.wallet.models.PendingTransactionType
import java.text.SimpleDateFormat
import java.util.*
/**
[REDACTED_AUTHOR]
*/
sealed class HistoryItemData(
val viewType: Int,
val itemId: Long,
) {
data class Date(val date: String) : HistoryItemData(0, date.hashCode().toLong())
data class TransactionData(val data: HistoryTransactionData) : HistoryItemData(1, data.address.hashCode().toLong())
}
data class HistoryTransactionData(
private val transactionData: TransactionData,
private val walletAddress: String,
) {
val isInProgress: Boolean = transactionData.status == TransactionStatus.Unconfirmed
val transactionType: PendingTransactionType = when {
transactionData.sourceAddress.lowercase() == walletAddress.lowercase() -> PendingTransactionType.Outgoing
transactionData.destinationAddress.lowercase() == walletAddress.lowercase() -> PendingTransactionType.Incoming
else -> PendingTransactionType.Unknown
}
val address: String = when (transactionType) {
PendingTransactionType.Incoming -> transactionData.sourceAddress.reduceAddress()
PendingTransactionType.Outgoing -> transactionData.destinationAddress.reduceAddress()
PendingTransactionType.Unknown -> "Unknown address"
}
val time: String = transactionData.date?.let {
SimpleDateFormat("HH:mm").format(it.time)
} ?: "00:00"
val date: String = calculateDate()
val txSign: String = when (transactionType) {
PendingTransactionType.Incoming -> "+"
PendingTransactionType.Outgoing -> "-"
PendingTransactionType.Unknown -> ""
}
val amountValue: String = transactionData.amount.value
?.toFormattedCurrencyString(8, transactionData.amount.currencySymbol) ?: "0"
private fun calculateDate(): String {
val calendarTx = transactionData.date.guard { return "Unknown" }
val calendarNow = Calendar.getInstance()
val oldDateFormatter = SimpleDateFormat("dd.MM.yyyy")
val date = if (calendarTx.get(Calendar.YEAR) == calendarNow.get(Calendar.YEAR)) {
val dayOfYearTx = calendarTx.get(Calendar.DAY_OF_YEAR)
val dayOfYearNow = calendarNow.get(Calendar.DAY_OF_YEAR)
when {
dayOfYearNow == dayOfYearTx -> "Today"
dayOfYearNow - dayOfYearTx == 1 -> "Tomorrow"
else -> oldDateFormatter.format(calendarTx.time)
}
} else {
oldDateFormatter.format(calendarTx.time)
}
return date
}
@Suppress("MagicNumber")
private fun String.reduceAddress(): String = "${substring(0..5)}...${substring(length - 4)}"
}

View file

@ -0,0 +1,113 @@
package com.tangem.tap.features.wallet.ui.wallet.saltPay.rv
import android.graphics.PorterDuff
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.tangem.tap.common.extensions.getColor
import com.tangem.tap.common.extensions.setDrawable
import com.tangem.tap.common.extensions.show
import com.tangem.tap.features.wallet.models.PendingTransactionType
import com.tangem.wallet.R
import com.tangem.wallet.databinding.ItemSaltPayTxHistoryBinding
import com.tangem.wallet.databinding.ItemSaltPayTxHistoryDateBinding
/**
[REDACTED_AUTHOR]
*/
internal class TxHistoryAdapter : ListAdapter<HistoryItemData, BaseTxHistoryVH>(DiffUtilCallback) {
override fun getItemId(position: Int): Long = if (currentList.isEmpty()) {
0L
} else {
currentList[position].itemId
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseTxHistoryVH {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
0 -> {
val layout = ItemSaltPayTxHistoryDateBinding.inflate(inflater, parent, false)
DateItemVH(layout)
}
1 -> {
val layout = ItemSaltPayTxHistoryBinding.inflate(inflater, parent, false)
TransactionItemVH(layout)
}
else -> throw UnsupportedOperationException()
}
}
override fun getItemViewType(position: Int): Int = currentList[position].viewType
override fun onBindViewHolder(holder: BaseTxHistoryVH, position: Int) {
holder.bind(currentList[position])
}
private object DiffUtilCallback : DiffUtil.ItemCallback<HistoryItemData>() {
override fun areContentsTheSame(oldItem: HistoryItemData, newItem: HistoryItemData) = oldItem == newItem
override fun areItemsTheSame(oldItem: HistoryItemData, newItem: HistoryItemData) = oldItem == newItem
}
}
internal abstract class BaseTxHistoryVH(layout: View) : RecyclerView.ViewHolder(layout) {
abstract fun bind(data: HistoryItemData)
}
private class DateItemVH(
private val binding: ItemSaltPayTxHistoryDateBinding,
) : BaseTxHistoryVH(binding.root) {
override fun bind(data: HistoryItemData) = with(binding) {
val vhData = (data as? HistoryItemData.Date)?.date ?: return
tvTitleDate.text = vhData
}
}
private class TransactionItemVH(
private val binding: ItemSaltPayTxHistoryBinding,
) : BaseTxHistoryVH(binding.root) {
override fun bind(data: HistoryItemData) {
val vhData = (data as? HistoryItemData.TransactionData)?.data ?: return
setupImage(vhData)
setupTitle(vhData)
setupSubtitle(vhData)
setupBalance(vhData)
}
private fun setupImage(data: HistoryTransactionData) = with(binding) {
val drawable = when (data.transactionType) {
PendingTransactionType.Incoming -> R.drawable.ic_tx_incoming
PendingTransactionType.Outgoing -> R.drawable.ic_tx_outgoing
PendingTransactionType.Unknown -> null
}
drawable?.let { imvTx.setDrawable(it) }
if (data.isInProgress) {
imvTx.setColorFilter(imvTx.getColor(R.color.icon_attention), PorterDuff.Mode.SRC_ATOP)
} else {
imvTx.clearColorFilter()
}
}
private fun setupTitle(data: HistoryTransactionData) = with(binding) {
tvTxHash.text = data.address
}
private fun setupSubtitle(data: HistoryTransactionData) = with(binding) {
tvTxStatus.show(data.isInProgress)
tvTxTime.show(!data.isInProgress)
tvTxTime.text = data.time
}
private fun setupBalance(data: HistoryTransactionData) = with(binding) {
tvTxAmountSign.text = data.txSign
tvTxAmountValue.text = data.amountValue
}
}

View file

@ -9,8 +9,9 @@ import com.tangem.common.map
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.ScanResponse
import com.tangem.domain.common.util.UserWalletId
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Basic
import com.tangem.tap.common.analytics.events.MyWallets
import com.tangem.tap.common.analytics.paramsInterceptor.BatchIdParamsInterceptor
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.redux.AppState
@ -140,7 +141,7 @@ internal class WalletSelectorMiddleware {
)
ScanCardProcessor.scan(
analyticsEvent = MyWallets.CardWasScanned(),
analyticsEvent = Basic.CardWasScanned(AnalyticsParam.ScannedFrom.MyWallets),
onWalletNotCreated = {
// No need to rollback policy, continue with the policy set before the card scan
store.dispatchOnMain(WalletSelectorAction.AddWallet.Success)
@ -209,9 +210,6 @@ internal class WalletSelectorMiddleware {
.doOnSuccess {
val selectedUserWallet = userWalletsListManager.selectedUserWalletSync
if (selectedUserWallet != null) {
val batchId = selectedUserWallet.scanResponse.card.batchId
Analytics.addParamsInterceptor(BatchIdParamsInterceptor(batchId))
store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Wallet))
store.onUserWalletSelected(selectedUserWallet)
}

View file

@ -47,7 +47,10 @@ internal class WalletSelectorBottomSheetFragment : ComposeBottomSheetFragment<Wa
@OptIn(ExperimentalComposeUiApi::class)
@Composable
override fun ScreenContent(state: WalletSelectorScreenState, modifier: Modifier) {
override fun ScreenContent(
state: WalletSelectorScreenState,
modifier: Modifier,
) {
val snackbarHostState = remember { SnackbarHostState() }
val errorMessage by rememberUpdatedState(newValue = state.error?.resolveReference())
val dialog by rememberUpdatedState(newValue = state.dialog)

View file

@ -4,7 +4,17 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.Text
@ -29,6 +39,7 @@ import com.tangem.core.ui.components.SpacerW6
import com.tangem.core.ui.components.SpacerW8
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.common.compose.TangemTypography
import com.tangem.tap.common.extensions.cardImageData
import com.tangem.tap.features.walletSelector.ui.model.MultiCurrencyUserWalletItem
import com.tangem.tap.features.walletSelector.ui.model.SingleCurrencyUserWalletItem
import com.tangem.tap.features.walletSelector.ui.model.UserWalletItem
@ -100,7 +111,7 @@ private fun WalletCardImage(
SubcomposeAsyncImage(
modifier = cardImageModifier,
model = ImageRequest.Builder(LocalContext.current)
.data(cardImageUrl)
.cardImageData(cardImageUrl)
.crossfade(true)
.build(),
loading = { CardImageShimmer(modifier = cardImageModifier) },

View file

@ -5,6 +5,7 @@ import com.tangem.common.core.TangemError
import org.rekotlin.Action
internal sealed interface WelcomeAction : Action {
object OnCreate : WelcomeAction
object ProceedWithBiometrics : WelcomeAction {
object Success : WelcomeAction
data class Error(val error: TangemError) : WelcomeAction

View file

@ -4,9 +4,13 @@ import android.content.Intent
import com.tangem.common.core.TangemSdkError
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.ScanResponse
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Basic
import com.tangem.tap.common.analytics.events.SignIn
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.eraseContext
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.navigation.AppScreen
@ -38,6 +42,10 @@ internal class WelcomeMiddleware {
private fun handleAction(action: WelcomeAction, state: WelcomeState) {
when (action) {
is WelcomeAction.OnCreate -> {
Analytics.eraseContext()
Analytics.send(SignIn.ScreenOpened())
}
is WelcomeAction.ProceedWithBiometrics -> {
proceedWithBiometrics(state)
}
@ -107,7 +115,7 @@ internal class WelcomeMiddleware {
useBiometricsForAccessCode = preferencesStorage.shouldSaveAccessCodes,
)
ScanCardProcessor.scan(
analyticsEvent = SignIn.CardWasScanned(),
analyticsEvent = Basic.CardWasScanned(AnalyticsParam.ScannedFrom.SignIn),
onSuccess = { scanResponse ->
scope.launch { onCardScanned(scanResponse) }
},

View file

@ -26,6 +26,7 @@ internal object WelcomeReducer {
is WelcomeAction.ProceedWithBiometrics.Success -> state.copy(isUnlockWithBiometricsInProgress = false)
is WelcomeAction.ProceedWithCard.Success -> state.copy(isUnlockWithCardInProgress = false)
is WelcomeAction.CloseError -> state.copy(error = null)
else -> state
}
}
}

View file

@ -19,14 +19,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.fragment.app.viewModels
import com.tangem.core.analytics.Analytics
import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.fragments.ComposeFragment
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.common.analytics.events.SignIn
import com.tangem.tap.features.details.ui.cardsettings.resolveReference
import com.tangem.tap.features.welcome.redux.WelcomeAction
import com.tangem.tap.features.welcome.ui.components.BiometricsLockoutDialog
import com.tangem.tap.features.welcome.ui.components.WelcomeScreenContent
import com.tangem.tap.store
import com.tangem.wallet.R
internal class WelcomeFragment : ComposeFragment<WelcomeScreenState>() {
@ -34,7 +34,7 @@ internal class WelcomeFragment : ComposeFragment<WelcomeScreenState>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Analytics.send(SignIn.ScreenOpened())
store.dispatch(WelcomeAction.OnCreate)
}
@Composable
@ -43,7 +43,10 @@ internal class WelcomeFragment : ComposeFragment<WelcomeScreenState>() {
}
@Composable
override fun ScreenContent(state: WelcomeScreenState, modifier: Modifier) {
override fun ScreenContent(
state: WelcomeScreenState,
modifier: Modifier,
) {
val snackbarHostState = remember { SnackbarHostState() }
val errorMessage by rememberUpdatedState(newValue = state.error?.resolveReference())
val biometricsLockoutDialog by rememberUpdatedState(newValue = state.biometricsLockoutDialog)

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