Updated on 2026-08-14

This commit is contained in:
Tangem 2023-01-30 17:16:18 +03:00
commit 10206ce692
92 changed files with 964 additions and 564 deletions

View file

@ -20,6 +20,7 @@ import com.tangem.tap.features.onboarding.products.wallet.redux.BackupDialog
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.InterruptOnboardingDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.NoFundsForActivationDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.PutVisaCardDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.RegistrationErrorDialog
import com.tangem.tap.features.onboarding.products.wallet.saltPay.dialog.SaltPayDialog
import com.tangem.tap.features.onboarding.products.wallet.ui.dialogs.AddMoreBackupCardsDialog
@ -132,6 +133,7 @@ class DialogManager : StoreSubscriber<GlobalState> {
is BackupDialog.UnfinishedBackupFound -> UnfinishedBackupFoundDialog.create(context)
is BackupDialog.ConfirmDiscardingBackup -> ConfirmDiscardingBackupDialog.create(context)
is SaltPayDialog.Activation.NoGas -> NoFundsForActivationDialog.create(context)
is SaltPayDialog.Activation.PutVisaCard -> PutVisaCardDialog.create(context)
is SaltPayDialog.Activation.OnError -> RegistrationErrorDialog.create(context, state.dialog)
is WalletDialog.CurrencySelectionDialog -> CurrencySelectionDialog.create(state.dialog, context)
is WalletDialog.ChooseTradeActionDialog -> ChooseTradeActionBottomSheetDialog(context, state.dialog)

View file

@ -42,7 +42,7 @@ class IntentHandler {
intent.action = null
store.dispatch(NavigationAction.NavigateTo(AppScreen.Home))
store.dispatch(NavigationAction.PopBackTo(AppScreen.Home))
store.dispatch(HomeAction.ReadCard)
store.dispatch(HomeAction.ReadCard())
}
}
}

View file

@ -2,79 +2,157 @@ package com.tangem.tap.common.analytics.converters
import com.tangem.common.Converter
import com.tangem.common.extensions.isZero
import com.tangem.domain.common.CardTypesResolver
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.filters.BasicTopUpFilter
import com.tangem.tap.domain.model.WalletDataModel
import com.tangem.tap.domain.model.builders.UserWalletIdBuilder
import com.tangem.tap.features.wallet.redux.ProgressState
import com.tangem.tap.features.wallet.redux.WalletData
import com.tangem.tap.features.wallet.redux.WalletState
import com.tangem.tap.features.wallet.redux.reducers.calculateTotalCryptoAmount
import timber.log.Timber
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
class BasicSignInEventConverter(
private val scanResponse: ScanResponse,
) : Converter<WalletState, Basic.SignedIn?> {
class BasicEventsPreChecker {
override fun convert(value: WalletState): Basic.SignedIn? {
if (!statesIsReadyToCreateEvent(scanResponse.cardTypesResolver, value)) return null
val cardCurrency = ParamCardCurrencyConverter().convert(scanResponse.cardTypesResolver) ?: return null
fun tryToSend(converterData: BasicEventsSourceData) {
if (!isReadyToSend(converterData)) return
BasicSignInEventConverter().convert(converterData)?.let { Analytics.send(it) }
BasicTopUpEventConverter().convert(converterData)?.let { Analytics.send(it) }
}
private fun isReadyToSend(data: BasicEventsSourceData): Boolean {
val (scanResponse, walletState, biometricsWalletDataModels) = data
if (walletState.derivationsCheckIsScheduled) {
Timber.d("FAILED: derivationsCheckIsScheduled")
return false
}
if (scanResponse.cardTypesResolver.isMultiwalletAllowed() && walletState.missingDerivations.isNotEmpty()) {
Timber.d("FAILED: isMultiwalletAllowed || missingDerivations.isNotEmpty")
return false
}
if (biometricsWalletDataModels == null) {
Timber.d("SWITCH: OLD")
val walletsDataFromStores = data.walletState.walletsDataFromStores
if (walletsDataFromStores.isEmpty()) {
Timber.d("FAILED: walletsDataFromStores.isEmpty")
return false
}
val totalBalanceState = data.walletState.totalBalance?.state
if (totalBalanceState == null || totalBalanceState == ProgressState.Loading ||
totalBalanceState == ProgressState.Refreshing
) {
Timber.d("FAILED: totalBalanceState: ${totalBalanceState?.name}")
return false
}
val balancesCount = walletsDataFromStores
.map { if (it.currencyData.amount == null) 0 else 1 }
.reduce { acc, i -> acc + i }
if (balancesCount != walletsDataFromStores.size) {
Timber.d("FAILED: balancesCount != walletsDataFromStores.size")
return false
}
} else {
Timber.d("SWITCH: BIOMETRICS")
if (biometricsWalletDataModels.isEmpty()) {
Timber.d("FAILED: biometricsWalletDataModels.isEmpty")
return false
}
if (biometricsWalletDataModels.any {
it.status is WalletDataModel.Loading ||
it.status is WalletDataModel.NoAccount ||
it.status is WalletDataModel.Unreachable ||
it.status is WalletDataModel.MissedDerivation ||
it.status.isErrorStatus
}) {
Timber.d("FAILED: by status")
return false
}
}
Timber.d("SUCCESS")
return true
}
}
/**
* With biometrics enabled, we should check its storage instead of "WalletState.walletsDataFromStores"
* because the latter is updated after some time.
* @property biometricsWalletDataModels - wallet data models from the 'WalletStoresManager'. If null, then
* the "WalletState.walletsDataFromStores" will be used to determine appropriate state
*/
data class BasicEventsSourceData(
val scanResponse: ScanResponse,
val walletState: WalletState,
val biometricsWalletDataModels: List<WalletDataModel>?,
) {
val batchId: String by lazy { scanResponse.card.batchId }
val userWalletIdStringValue: String? by lazy {
UserWalletIdBuilder.scanResponse(scanResponse)
.build()
?.stringValue
}
val paramCardCurrency: AnalyticsParam.CardCurrency? by lazy {
ParamCardCurrencyConverter().convert(scanResponse.cardTypesResolver)
}
val paramCardBalanceState: AnalyticsParam.CardBalanceState by lazy { calculateAmount().toCardBalanceState() }
private fun calculateAmount(): BigDecimal {
return biometricsWalletDataModels?.calculateTotalCryptoAmount()
?: walletState.walletsDataFromStores.calculateTotalCryptoAmount()
}
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)
}
}
class BasicSignInEventConverter : Converter<BasicEventsSourceData, Basic.SignedIn?> {
override fun convert(value: BasicEventsSourceData): Basic.SignedIn? {
if (value.paramCardCurrency == null || value.userWalletIdStringValue == null) return null
return Basic.SignedIn(
state = AnalyticsParam.CardBalanceState.from(value.walletsDataFromStores),
currency = cardCurrency,
batch = scanResponse.card.batchId,
state = value.paramCardBalanceState,
currency = value.paramCardCurrency!!,
batch = value.batchId,
).apply {
filterData = UserWalletIdBuilder.scanResponse(scanResponse)
.build()
?.stringValue
filterData = value.userWalletIdStringValue
}
}
}
class BasicTopUpEventConverter(
private val scanResponse: ScanResponse,
) : Converter<WalletState, Basic.ToppedUp?> {
class BasicTopUpEventConverter : Converter<BasicEventsSourceData, Basic.ToppedUp?> {
override fun convert(value: WalletState): Basic.ToppedUp? {
if (!statesIsReadyToCreateEvent(scanResponse.cardTypesResolver, value)) return null
val cardCurrency = ParamCardCurrencyConverter().convert(scanResponse.cardTypesResolver) ?: return null
override fun convert(value: BasicEventsSourceData): Basic.ToppedUp? {
if (value.paramCardCurrency == null || value.userWalletIdStringValue == null) return null
val data = BasicTopUpFilter.Data(
walletId = UserWalletIdBuilder.scanResponse(scanResponse).build()?.stringValue ?: "",
cardBalanceState = AnalyticsParam.CardBalanceState.from(value.walletsDataFromStores),
walletId = value.userWalletIdStringValue!!,
cardBalanceState = value.paramCardBalanceState,
)
return Basic.ToppedUp(cardCurrency).apply { filterData = data }
return Basic.ToppedUp(value.paramCardCurrency!!).apply { filterData = data }
}
}
private fun AnalyticsParam.CardBalanceState.Companion.from(
walletsData: List<WalletData>,
): AnalyticsParam.CardBalanceState {
val totalCryptoAmount = walletsData.calculateTotalCryptoAmount()
return when {
totalCryptoAmount.isZero() -> AnalyticsParam.CardBalanceState.Empty
else -> AnalyticsParam.CardBalanceState.Full
}
}
private fun statesIsReadyToCreateEvent(cardTypesResolver: CardTypesResolver, state: WalletState): Boolean {
if (cardTypesResolver.isMultiwalletAllowed() && state.missingDerivations.isNotEmpty()) return false
if (state.walletsDataFromStores.isEmpty()) return false
val totalBalanceState = state.totalBalance?.state ?: return false
if (totalBalanceState == ProgressState.Loading || totalBalanceState == ProgressState.Refreshing) return false
val balancesCount = state.walletsDataFromStores.map {
if (it.currencyData.amount == null) 0 else 1
}.reduce { acc, i -> acc + i }
if (balancesCount != state.walletsStores.size) return false
return true
}

View file

@ -18,6 +18,7 @@ class ParamCardCurrencyConverter : Converter<CardTypesResolver, AnalyticsParam.C
value.isTangemNote() -> AnalyticsParam.CurrencyType.Blockchain(value.getBlockchain())
value.isTangemTwins() -> AnalyticsParam.CurrencyType.Blockchain(Blockchain.Bitcoin)
value.isSaltPay() -> AnalyticsParam.CurrencyType.Token(SaltPayWorkaround.tokenFrom(Blockchain.SaltPay))
value.getBlockchain() != Blockchain.Unknown -> AnalyticsParam.CurrencyType.Blockchain(value.getBlockchain())
value.getPrimaryToken() != null -> AnalyticsParam.CurrencyType.Token(value.getPrimaryToken()!!)
else -> null
} ?: return null

View file

@ -8,19 +8,19 @@ import com.tangem.tap.common.shop.data.ProductType
/**
[REDACTED_AUTHOR]
*/
class ShopOrderToEventConverter : Converter<Pair<Storefront.Order, ProductType>, Shop.Purchased> {
class ShopOrderToEventConverter : Converter<Pair<Storefront.Checkout, ProductType>, Shop.Purchased> {
override fun convert(value: Pair<Storefront.Order, ProductType>): Shop.Purchased {
val order = value.first
override fun convert(value: Pair<Storefront.Checkout, ProductType>): Shop.Purchased {
val checkout = value.first
val productType = value.second
val sku = order.lineItems.edges.firstOrNull()?.node?.variant?.sku ?: productType.sku
val sku = checkout.lineItems?.edges?.firstOrNull()?.node?.variant?.sku ?: productType.sku
val count = when (productType) {
ProductType.WALLET_2_CARDS -> "2"
ProductType.WALLET_3_CARDS -> "3"
}
val amount = "${order.totalPriceV2.amount} ${order.totalPriceV2.currencyCode.name}"
val code = (order.discountApplications.edges.firstOrNull()?.node as? Storefront.DiscountCodeApplication)?.code
val amount = "${checkout.totalPriceV2.amount} ${checkout.totalPriceV2.currencyCode.name}"
val code = (checkout.discountApplications.edges.firstOrNull()?.node as? Storefront.DiscountCodeApplication)?.code
return Shop.Purchased(sku, count, amount, code)
}

View file

@ -7,14 +7,14 @@ sealed class MyWallets(
params: Map<String, String> = mapOf(),
) : AnalyticsEvent("My Wallets", event, params) {
object MyWalletsScreenOpened : MyWallets(event = "My Wallets Screen Opened")
object CardWasScanned : MyWallets(event = "Card Was Scanned")
object WalletUnlockTapped : MyWallets(event = "Wallet Unlock Tapped")
class MyWalletsScreenOpened : MyWallets(event = "My Wallets Screen Opened")
class CardWasScanned : MyWallets(event = "Card Was Scanned")
object Button {
object ScanNewCard : MyWallets(event = "Button - Scan New Card")
object UnlockWithBiometrics : MyWallets(event = "Button - Unlock all with Face ID")
object EditWalletTapped : MyWallets(event = "Button - Edit Wallet Tapped")
object DeleteWalletTapped : MyWallets(event = "Button - Delete Wallet Tapped")
sealed class Button {
class ScanNewCard : MyWallets(event = "Button - Scan New Card")
class UnlockWithBiometrics : MyWallets(event = "Button - Unlock all with Face ID")
class EditWalletTapped : MyWallets(event = "Button - Edit Wallet Tapped")
class DeleteWalletTapped : MyWallets(event = "Button - Delete Wallet Tapped")
class WalletUnlockTapped : MyWallets(event = "Button - Wallet Unlock Tapped")
}
}

View file

@ -75,6 +75,8 @@ sealed class Onboarding(
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 EnableBiometrics(state: AnalyticsParam.OnOffState) : Onboarding(
category = "Onboarding / Biometric",
event = "Enable Biometric",

View file

@ -21,6 +21,7 @@ sealed class Settings(
class ButtonCardSettings : Settings(event = "Button - Card Settings")
class ButtonAppSettings : Settings(event = "Button - App Settings")
class ButtonCreateBackup : Settings(event = "Button - Create Backup")
class ButtonWalletConnect : Settings(event = "Button - Wallet Connect")
class ButtonSocialNetwork(network: SocialNetwork) : Settings(
event = "Button - Social Network",

View file

@ -11,7 +11,7 @@ sealed class Shop(
params: Map<String, String> = mapOf(),
) : AnalyticsEvent("Shop", event, params) {
class ScreenOpened : IntroductionProcess("Shop Screen Opened")
class ScreenOpened : Shop("Shop Screen Opened")
class Purchased(sku: String, count: String, amount: String, couponCode: String?) : Shop(
event = "Purchased",
@ -23,8 +23,8 @@ sealed class Shop(
).filterNotNull(),
)
class Redirected(partnerName: String) : Shop(
class Redirected(partnerName: String?) : Shop(
event = "Redirected",
params = mapOf("Partner" to partnerName),
params = partnerName?.let { mapOf("Partner" to partnerName) } ?: mapOf(),
)
}

View file

@ -0,0 +1,19 @@
package com.tangem.tap.common.analytics.events
import com.tangem.core.analytics.AnalyticsEvent
/**
[REDACTED_AUTHOR]
*/
sealed class SignIn(
event: String,
params: Map<String, String> = mapOf(),
error: Throwable? = null,
) : 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

@ -46,14 +46,14 @@ sealed class Token(
params = mapOf("Token" to type.value),
)
sealed class Recieve(
sealed class Receive(
event: String,
params: Map<String, String> = mapOf(),
) : Token("Token / Recieve", event, params) {
) : Token("Token / Receive", event, params) {
class ScreenOpened : Recieve("Recieve Screen Opened")
class ButtonCopyAddress : Recieve("Button - Copy Address")
class ButtonShareAddress : Recieve("Button - Share Address")
class ScreenOpened : Receive("Receive Screen Opened")
class ButtonCopyAddress : Receive("Button - Copy Address")
class ButtonShareAddress : Receive("Button - Share Address")
}
sealed class Send(

View file

@ -11,6 +11,7 @@ sealed class WalletConnect(
error: Throwable? = null,
) : AnalyticsEvent("Wallet Connect", event, params, error) {
class ScreenOpened : WalletConnect(event = "WC Screen Opened")
class NewSessionEstablished : WalletConnect("New Session Established")
class SessionDisconnected : WalletConnect("Session Disconnected")
class RequestSigned : WalletConnect("Request Signed")

View file

@ -1,25 +0,0 @@
package com.tangem.tap.common.analytics.filters
import com.tangem.core.analytics.api.AnalyticsEventFilter
import com.tangem.core.analytics.api.AnalyticsHandler
import com.tangem.core.analytics.AnalyticsEvent
import com.tangem.tap.common.analytics.events.Shop
import com.tangem.tap.common.analytics.handlers.appsFlyer.AppsFlyerAnalyticsHandler
import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandler
/**
[REDACTED_AUTHOR]
*/
class ShopPurchasedEventFilter : AnalyticsEventFilter {
override fun canBeAppliedTo(event: AnalyticsEvent): Boolean = event is Shop.Purchased
override fun canBeSent(event: AnalyticsEvent): Boolean = true
override fun canBeConsumedByHandler(handler: AnalyticsHandler, event: AnalyticsEvent): Boolean {
return when (handler) {
is AppsFlyerAnalyticsHandler, is FirebaseAnalyticsHandler -> true
else -> false
}
}
}

View file

@ -1,7 +1,10 @@
package com.tangem.tap.common.analytics.handlers.appsFlyer
import com.appsflyer.AFInAppEventType
import com.tangem.core.analytics.AnalyticsEvent
import com.tangem.core.analytics.api.AnalyticsHandler
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
import com.tangem.tap.common.analytics.events.Shop
class AppsFlyerAnalyticsHandler(
private val client: AppsFlyerAnalyticsClient,
@ -13,6 +16,14 @@ class AppsFlyerAnalyticsHandler(
client.logEvent(event, params)
}
override fun send(event: AnalyticsEvent) {
if (event is Shop.Purchased) {
send(AFInAppEventType.PURCHASE, event.params)
} else {
super.send(event)
}
}
companion object {
const val ID = "AppsFlyer"
}

View file

@ -1,10 +1,12 @@
package com.tangem.tap.common.analytics.handlers.firebase
import com.google.firebase.analytics.FirebaseAnalytics
import com.tangem.core.analytics.AnalyticsEvent
import com.tangem.core.analytics.api.AnalyticsHandler
import com.tangem.core.analytics.api.ErrorEventHandler
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
import com.tangem.tap.common.analytics.converters.AnalyticsErrorConverter
import com.tangem.tap.common.analytics.events.Shop
class FirebaseAnalyticsHandler(
private val client: FirebaseAnalyticsClient,
@ -18,17 +20,23 @@ class FirebaseAnalyticsHandler(
override fun send(event: AnalyticsEvent) {
val error = event.error
if (error == null) {
super.send(event)
} else {
val errorConverter = AnalyticsErrorConverter()
if (!errorConverter.canBeHandled(error)) return
when {
error != null -> {
val errorConverter = AnalyticsErrorConverter()
if (!errorConverter.canBeHandled(error)) return
val errorParams = errorConverter.convert(error).toMutableMap()
errorParams["Category"] = event.category
errorParams["Event"] = event.event
errorParams.putAll(event.params)
send(error, errorParams)
val errorParams = errorConverter.convert(error).toMutableMap()
errorParams["Category"] = event.category
errorParams["Event"] = event.event
errorParams.putAll(event.params)
send(error, errorParams)
}
event is Shop.Purchased -> {
send(FirebaseAnalytics.Event.PURCHASE, event.params)
}
else -> {
super.send(event)
}
}
}

View file

@ -11,7 +11,7 @@ import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.*
// todo move extensions to utils
// TODO: move extensions to utils
fun BigDecimal.toFormattedString(
decimals: Int,
roundingMode: RoundingMode = RoundingMode.DOWN,
@ -75,7 +75,7 @@ fun BigDecimal.toFormattedFiatValue(
): String {
val fiatValue = this.setScale(2, RoundingMode.HALF_UP)
.let { if (formatWithSpaces) it.formatWithSpaces() else it }
return "$fiatValue $fiatCurrencyName"
return "$fiatValue$fiatCurrencyName"
}
fun BigDecimal.stripZeroPlainString(): String = this.stripTrailingZeros().toPlainString()

View file

@ -2,6 +2,7 @@ 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.extensions.sendEmail
import com.tangem.tap.common.log.TangemLogCollector
@ -108,6 +109,7 @@ class FeedbackManager(
}
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)

View file

@ -202,8 +202,8 @@ class TangemShopService(application: Application, shopifyShop: ShopifyShop) {
suspend fun waitForCheckout(productType: ProductType) {
val result = shopifyService.checkout(true, checkouts[productType]!!.id)
result.onSuccess { checkout ->
checkout.order?.let {
val event = ShopOrderToEventConverter().convert(it to productType)
if (checkout.order != null && checkout.lineItems != null) {
val event = ShopOrderToEventConverter().convert(checkout to productType)
Analytics.send(event)
}
}