From 057a972768cf19224dbdb65394d579bd039076c0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 26 Jan 2026 10:25:03 +0300 Subject: [PATCH 1/9] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 5802616373..d8e493fcf3 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.32-1386" +tangemBlockchainSdk = "releases-5.32-1388" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.32-574" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 58428d773b42fadd1e64bbd060359dfe3def2b8f Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 18 Jan 2026 18:58:54 +0400 Subject: [PATCH 2/9] Updated on 2026-08-14 --- .../tangempay/TangemPayAnalyticsEvents.kt | 124 ++++++++++++++++++ .../TangemPayCardDetailsBlockStateFactory.kt | 3 +- .../entity/TangemPayDetailsStateFactory.kt | 2 + .../entity/TangemPayDetailsTopBarConfig.kt | 1 + .../tangempay/entity/TangemPayDetailsUM.kt | 3 +- .../model/TangemPayCardDetailsBlockModel.kt | 17 ++- .../model/TangemPayChangePinModel.kt | 13 +- .../tangempay/model/TangemPayDetailsModel.kt | 24 ++++ .../model/TangemPayTxHistoryDetailsModel.kt | 5 + .../tangempay/model/TangemPayViewPinModel.kt | 4 + .../ui/TangemPayAddToWalletScreen.kt | 2 +- .../tangempay/ui/TangemPayCardDetailsBlock.kt | 15 ++- .../tangempay/ui/TangemPayDetailsScreen.kt | 15 ++- .../model/TangemPayOnboardingModel.kt | 1 + 14 files changed, 212 insertions(+), 17 deletions(-) diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt index 2f944349f5..e4b6af4d70 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt @@ -52,4 +52,128 @@ sealed class TangemPayAnalyticsEvents( categoryName = "Visa Screen", event = "Button - Visa Swap", ) + + class ChooseWalletPopup : TangemPayAnalyticsEvents( + categoryName = "Visa Onboarding", + event = "Choose Wallet Popup", + ) + + class CardSettingsClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Card Settings", + ) + + class TermsAndLimitsClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Terms And Limits", + ) + + class FreezeCardClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Freeze Card", + ) + + class FreezeCardConfirmShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Popup - Freeze Confirmation", + ) + + class FreezeCardConfirmClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Freeze Confirmation On Popup", + ) + + class UnfreezeCardClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Unfreeze Card", + ) + + class UnfreezeCardConfirmShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Popup - Unfreeze Confirmation", + ) + + class UnfreezeCardConfirmClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Unfreeze Confirmation On Popup", + ) + + class PinCodeClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - PIN Code", + ) + + class ChangePinScreenShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Screen - Change PIN", + ) + + class ChangePinSubmitClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Set PIN On Change PIN Screen", + ) + + class ChangePinSuccessShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Message - PIN Setup Success", + ) + + class CurrentPinShown : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Popup - Current PIN", + ) + + class ChangePinOnCurrentPinClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Change PIN On Current PIN", + ) + + class ViewCardDetailsClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - View Card Details", + ) + + class CopyCardNumberClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Copy Card Number", + ) + + class CopyCardExpiryClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Copy Card Expiry", + ) + + class CopyCardCVVClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Copy CVV", + ) + + class AddToWalletClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Add Card To Wallet", + ) + + class WithdrawClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Visa Withdraw", + ) + + class GoToSupportOnBetaBannerClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Go To Support On Beta Banner", + ) + + class TransactionInListClicked(type: String, status: String) : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Clicked On Transaction In List", + params = mapOf( + "type" to type, + "status" to status, + ), + ) + + class SupportOnTransactionPopupClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Button - Support On Transaction Popup", + ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt index 7904ea72d9..e79a05f253 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt @@ -3,12 +3,13 @@ package com.tangem.features.tangempay.entity import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.model.CardDataType import com.tangem.utils.StringsSigns internal class TangemPayCardDetailsBlockStateFactory( private val cardNumberEnd: String, private val onReveal: () -> Unit, - private val onCopy: (String) -> Unit, + private val onCopy: (String, CardDataType) -> Unit, ) { fun getInitialState() = TangemPayCardDetailsUM( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index 55b429059a..8fb38c33bc 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -18,6 +18,7 @@ import kotlinx.collections.immutable.toPersistentList @Suppress("LongParameterList") internal class TangemPayDetailsStateFactory( private val onBack: () -> Unit, + private val onOpenMenu: () -> Unit, private val intents: TangemPayDetailIntents, private val cardFrozenState: TangemPayCardFrozenState, private val converter: TangemPayCardFrozenStateConverter, @@ -47,6 +48,7 @@ internal class TangemPayDetailsStateFactory( return TangemPayDetailsUM( topBarConfig = TangemPayDetailsTopBarConfig( onBackClick = onBack, + onOpenMenu = onOpenMenu, items = listOfNotNull( TangemPayDetailsTopBarMenuItem( type = TangemPayDetailsTopBarMenuItemType.ChangePin, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt index 1010496a66..bd4daa10c9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt @@ -5,6 +5,7 @@ import kotlinx.collections.immutable.ImmutableList internal data class TangemPayDetailsTopBarConfig( val onBackClick: () -> Unit, + val onOpenMenu: () -> Unit, val items: ImmutableList?, ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 37663aa1f0..f67ed6b7b0 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -5,6 +5,7 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.model.CardDataType import kotlinx.collections.immutable.ImmutableList internal data class TangemPayDetailsUM( @@ -25,7 +26,7 @@ internal data class TangemPayCardDetailsUM( val cvv: String, val buttonText: TextReference = TextReference.EMPTY, val onClick: () -> Unit = {}, - val onCopy: (String) -> Unit = {}, + val onCopy: (String, CardDataType) -> Unit, val isHidden: Boolean = true, val isLoading: Boolean = false, val cardFrozenState: TangemPayCardFrozenState, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt index 5c83a87360..fcee864f89 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -9,6 +10,7 @@ import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayCardDetailsBlockStateFactory @@ -29,6 +31,7 @@ import com.tangem.utils.transformer.update as transformerUpdate private const val SHOW_DETAILS_TIME = 30_000L +@Suppress("LongParameterList") @Stable @ModelScoped internal class TangemPayCardDetailsBlockModel @Inject constructor( @@ -38,6 +41,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( private val clipboardManager: ClipboardManager, private val uiMessageSender: UiMessageSender, private val cardDetailsEventListener: CardDetailsEventListener, + private val analytics: AnalyticsEventHandler, ) : Model() { private val params: TangemPayCardDetailsBlockComponent.Params = paramsContainer.require() @@ -74,6 +78,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( } private fun revealCardDetails() { + analytics.send(TangemPayAnalyticsEvents.ViewCardDetailsClicked()) modelScope.launch { uiState.transformerUpdate( transformer = DetailsRevealProgressStateTransformer(onClickHide = ::hideCardDetails), @@ -115,7 +120,17 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( ) } - private fun copyData(text: String) { + private fun copyData(text: String, type: CardDataType) { + val event = when (type) { + CardDataType.Number -> TangemPayAnalyticsEvents.CopyCardNumberClicked() + CardDataType.Expiry -> TangemPayAnalyticsEvents.CopyCardExpiryClicked() + CardDataType.CVV -> TangemPayAnalyticsEvents.CopyCardCVVClicked() + } + analytics.send(event) clipboardManager.setText(text = text.filterNot { it.isWhitespace() }, isSensitive = true) } +} + +internal enum class CardDataType { + Number, Expiry, CVV } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt index 6d9f9d47f2..634b5a0e69 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -10,6 +11,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayChangePinUM @@ -30,17 +32,23 @@ internal class TangemPayChangePinModel @Inject constructor( private val uiMessageSender: UiMessageSender, private val router: Router, private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val analytics: AnalyticsEventHandler, ) : Model() { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() val uiState: StateFlow field = MutableStateFlow(getInitialState()) + init { + analytics.send(TangemPayAnalyticsEvents.ChangePinScreenShown()) + } + private fun onPinCodeChange(pin: String) { uiState.update(transformer = PinCodeChangeTransformer(newPin = pin)) } private fun onClickSubmit() { + analytics.send(TangemPayAnalyticsEvents.ChangePinSubmitClicked()) modelScope.launch { uiState.update { it.copy(submitButtonLoading = true) } val result = try { @@ -59,7 +67,10 @@ internal class TangemPayChangePinModel @Inject constructor( message = ToastMessage(resourceReference(R.string.tangempay_pin_validation_error_message)), ) } - SetPinResult.SUCCESS -> router.push(TangemPayDetailsInnerRoute.ChangePINSuccess) + SetPinResult.SUCCESS -> { + analytics.send(TangemPayAnalyticsEvents.ChangePinSuccessShown()) + router.push(TangemPayDetailsInnerRoute.ChangePINSuccess) + } SetPinResult.DECRYPTION_ERROR, SetPinResult.UNKNOWN_ERROR, null, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 8f7b249781..b2e479da7a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -86,6 +86,7 @@ internal class TangemPayDetailsModel @Inject constructor( private val cardFrozenStateConverter = TangemPayCardFrozenStateConverter(onUnfreezeClick = ::onClickUnfreezeCard) private val stateFactory = TangemPayDetailsStateFactory( onBack = router::pop, + onOpenMenu = ::onOpenMenu, intents = this, cardFrozenState = params.config.cardFrozenState, converter = cardFrozenStateConverter, @@ -127,6 +128,7 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onClickPinCode() { + analytics.send(TangemPayAnalyticsEvents.PinCodeClicked()) if (!params.config.isPinSet) { router.push(TangemPayDetailsInnerRoute.ChangePIN) } else { @@ -140,14 +142,19 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onClickFreezeCard() { + analytics.send(TangemPayAnalyticsEvents.FreezeCardClicked()) uiMessageSender.send(TangemPayMessagesFactory.createFreezeCardMessage(onFreezeClicked = ::freezeCard)) + analytics.send(TangemPayAnalyticsEvents.FreezeCardConfirmShown()) } override fun onClickUnfreezeCard() { + analytics.send(TangemPayAnalyticsEvents.UnfreezeCardClicked()) uiMessageSender.send(TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard)) + analytics.send(TangemPayAnalyticsEvents.UnfreezeCardConfirmShown()) } private fun freezeCard() { + analytics.send(TangemPayAnalyticsEvents.FreezeCardConfirmClicked()) modelScope.launch { val result = try { cardDetailsRepository.freezeCard(userWalletId = params.userWalletId, cardId = params.config.cardId) @@ -186,6 +193,7 @@ internal class TangemPayDetailsModel @Inject constructor( } private fun unfreezeCard() { + analytics.send(TangemPayAnalyticsEvents.UnfreezeCardConfirmClicked()) modelScope.launch { val result = try { cardDetailsRepository.unfreezeCard(userWalletId = params.userWalletId, cardId = params.config.cardId) @@ -243,6 +251,7 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onClickWithdraw() { + analytics.send(TangemPayAnalyticsEvents.WithdrawClicked()) val currentBalance = balance val depositAddress = currentBalance?.depositAddress if (currentBalance == null || depositAddress == null) { @@ -336,6 +345,7 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onContactSupportClicked() { + analytics.send(TangemPayAnalyticsEvents.GoToSupportOnBetaBannerClicked()) modelScope.launch { sendFeedbackEmailUseCase.invoke( type = FeedbackEmailType.Visa.FeatureIsBeta( @@ -356,6 +366,7 @@ internal class TangemPayDetailsModel @Inject constructor( } private fun onClickAddToWalletBlock() { + analytics.send(TangemPayAnalyticsEvents.AddToWalletClicked()) router.push(TangemPayDetailsInnerRoute.AddToWallet) } @@ -376,6 +387,10 @@ internal class TangemPayDetailsModel @Inject constructor( }.saveIn(addToWalletBannerJobHolder) } + private fun onOpenMenu() { + analytics.send(TangemPayAnalyticsEvents.CardSettingsClicked()) + } + override fun onClickSwap(data: TangemPayTopUpData) { analytics.send(TangemPayAnalyticsEvents.SwapClicked()) bottomSheetNavigation.dismiss() @@ -414,6 +429,7 @@ internal class TangemPayDetailsModel @Inject constructor( override fun onClickChangePin() { bottomSheetNavigation.dismiss() + analytics.send(TangemPayAnalyticsEvents.ChangePinOnCurrentPinClicked()) router.push(TangemPayDetailsInnerRoute.ChangePIN) } @@ -422,6 +438,13 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onTransactionClick(item: TangemPayTxHistoryItem) { + val (type, status) = when (item) { + is TangemPayTxHistoryItem.Collateral -> "collateral" to "unknown" + is TangemPayTxHistoryItem.Fee -> "fee" to "unknown" + is TangemPayTxHistoryItem.Payment -> "payment" to "unknown" + is TangemPayTxHistoryItem.Spend -> "spend" to item.status.name.lowercase() + } + analytics.send(TangemPayAnalyticsEvents.TransactionInListClicked(type = type, status = status)) bottomSheetNavigation.activate( configuration = TangemPayDetailsNavigation.TransactionDetails( transaction = item, @@ -431,6 +454,7 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onClickTermsAndLimits() { + analytics.send(TangemPayAnalyticsEvents.TermsAndLimitsClicked()) urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt index 8dae2f4f77..1d6f1f82fa 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayTxHistoryDetailsModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -9,6 +10,7 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent import com.tangem.features.tangempay.entity.TangemPayTxHistoryDetailsUM import com.tangem.features.tangempay.model.transformers.TangemPayTxHistoryDetailsConverter @@ -17,6 +19,7 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @Stable @ModelScoped internal class TangemPayTxHistoryDetailsModel @Inject constructor( @@ -25,6 +28,7 @@ internal class TangemPayTxHistoryDetailsModel @Inject constructor( private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val urlOpener: UrlOpener, private val balanceHidingSettings: GetBalanceHidingSettingsUseCase, + private val analytics: AnalyticsEventHandler, paramsContainer: ParamsContainer, ) : Model() { @@ -61,6 +65,7 @@ internal class TangemPayTxHistoryDetailsModel @Inject constructor( } private fun dispute() { + analytics.send(TangemPayAnalyticsEvents.SupportOnTransactionPopupClicked()) modelScope.launch { val walletMetaInfo = getWalletMetaInfoUseCase.invoke(params.userWalletId).getOrNull() ?: return@launch diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayViewPinModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayViewPinModel.kt index 14631dc022..a610d66541 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayViewPinModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayViewPinModel.kt @@ -1,10 +1,12 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.TangemPayViewPinComponent import com.tangem.features.tangempay.entity.TangemPayViewPinUM import com.tangem.features.tangempay.model.transformers.TangemPayViewPinErrorStateTransformer @@ -22,6 +24,7 @@ internal class TangemPayViewPinModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val analytics: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -30,6 +33,7 @@ internal class TangemPayViewPinModel @Inject constructor( field = MutableStateFlow(getInitialState()) init { + analytics.send(TangemPayAnalyticsEvents.CurrentPinShown()) getCardPin() } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt index eb2a5831d4..7fd6c66c61 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt @@ -187,7 +187,7 @@ private fun PreviewTangemPayAddToWalletScreen() { numberShort = "*1245", expiry = "••/••", cvv = "•••", - onCopy = {}, + onCopy = { _, _ -> }, onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index 403e3fe778..33e91f7745 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -33,6 +33,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.model.CardDataType private val CustomCardBlockColor = Color(0x1F828282) @@ -79,9 +80,9 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M cardNumber = state.number, expiry = state.expiry, cvv = state.cvv, - onCopyCardNumber = { state.onCopy(state.number) }, - onCopyCvv = { state.onCopy(state.cvv) }, - onCopyExpiry = { state.onCopy(state.expiry) }, + onCopyCardNumber = { state.onCopy(state.number, CardDataType.Number) }, + onCopyCvv = { state.onCopy(state.cvv, CardDataType.CVV) }, + onCopyExpiry = { state.onCopy(state.expiry, CardDataType.Expiry) }, onHideDetails = state.onClick, modifier = Modifier.graphicsLayer { rotationY = 180f }, ) @@ -309,7 +310,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide expiry = "", cvv = "", buttonText = resourceReference(R.string.tangempay_card_details_show_details), - onCopy = {}, + onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Frozen, ), @@ -321,7 +322,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide expiry = "", cvv = "", buttonText = resourceReference(R.string.tangempay_card_details_show_details), - onCopy = {}, + onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Unfrozen, ), @@ -333,7 +334,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide expiry = "", cvv = "", buttonText = resourceReference(R.string.tangempay_card_details_show_details), - onCopy = {}, + onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Pending, ), @@ -341,7 +342,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide isLoading = false, onClick = {}, buttonText = resourceReference(R.string.tangempay_card_details_hide_details), - onCopy = {}, + onCopy = { _, _ -> }, isHidden = false, number = "1234 5678 9012 3456", numberShort = "*3456", diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 255f5c37a5..8974d6b761 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -235,7 +235,12 @@ private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modi title = {}, actions = { AnimatedVisibility(visible = config.items != null && config.items.isNotEmpty()) { - IconButton(onClick = { showDropdownMenu = true }) { + IconButton( + onClick = { + config.onOpenMenu() + showDropdownMenu = true + }, + ) { Icon( painter = painterResource(id = R.drawable.ic_more_vertical_24), tint = TangemTheme.colors.icon.primary1, @@ -286,7 +291,7 @@ private fun TangemPayDetailsScreenPreview( numberShort = "*1245", expiry = "••/••", cvv = "•••", - onCopy = {}, + onCopy = { _, _ -> }, onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, @@ -299,7 +304,7 @@ private fun TangemPayDetailsScreenPreview( private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( collection = listOf( TangemPayDetailsUM( - topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, items = null), + topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, onOpenMenu = {}, items = null), pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), balanceBlockState = TangemPayDetailsBalanceBlockState.Content( actionButtons = persistentListOf( @@ -328,7 +333,7 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider }, onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt index 83700ab313..9181edcee3 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModel.kt @@ -150,6 +150,7 @@ internal class TangemPayOnboardingModel @Inject constructor( if (eligibleWalletsIds.size == 1) { checkCustomerInfo(userWalletId = eligibleWalletsIds[0]) } else { + analytics.send(TangemPayAnalyticsEvents.ChooseWalletPopup()) bottomSheetNavigation.activate(TangemPayOnboardingNavigation.WalletSelector(eligibleWalletsIds)) } } From d77875be513cf8bfd07f776f605dec336754f6b2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 13 Jan 2026 10:51:26 +0300 Subject: [PATCH 3/9] Updated on 2026-08-14 --- app/build.gradle.kts | 8 +- app/src/main/AndroidManifest.xml | 33 +++-- .../com/tangem/tap/ApplicationEntryPoint.kt | 3 + .../java/com/tangem/tap/TangemApplication.kt | 6 +- .../appsflyer/AppsFlyerDeepLinkListener.kt | 94 ++++++++++++++ .../appsflyer/AppsFlyerAnalyticsClient.kt | 95 ++++++++------ .../appsflyer/AppsFlyerAnalyticsHandler.kt | 7 +- .../AppsFlyerDeepLinkListenerTest.kt | 120 ++++++++++++++++++ .../converters/WalletIdBodyConverter.kt | 2 + .../di/AppsFlyerConversionStoreModule.kt | 21 +++ .../appsflyer/AppsFlyerConversionStore.kt | 10 ++ .../appsflyer/ConversionDataConverter.kt | 21 +++ .../DefaultAppsFlyerConversionStore.kt | 42 ++++++ .../data/common/currency/UserTokensSaver.kt | 2 + .../wallets/models/AppsFlyerConversionData.kt | 3 + 15 files changed, 414 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt create mode 100644 app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/di/AppsFlyerConversionStoreModule.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerConversionStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/ConversionDataConverter.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerConversionStore.kt create mode 100644 domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/AppsFlyerConversionData.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4fdc8ffde8..61936049d7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -108,6 +108,9 @@ configurations.androidTestImplementation { exclude(module = "protobuf-lite") } +tasks.withType().configureEach { + useJUnitPlatform() +} dependencies { implementation(projects.domain.legacy) @@ -393,11 +396,10 @@ dependencies { implementation(files("libs/dexprotector-annotations.jar")) /** Testing libraries */ + testImplementation(projects.test.core) testImplementation(projects.common.test) - testImplementation(deps.test.coroutine) testImplementation(deps.test.junit) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) + testRuntimeOnly(deps.test.junit5.engine) androidTestImplementation(deps.test.junit.android) androidTestImplementation(deps.test.espresso) androidTestImplementation(deps.test.espresso.intents) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ab376d6124..ebc857a78a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,9 +11,15 @@ - - - + + + @@ -36,7 +42,6 @@ @@ -286,9 +292,20 @@ + android:path="/pay-app" + android:scheme="https" /> + + + + + + + + + @@ -326,6 +343,6 @@ - - + + diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index 59dd05efbe..c40ccb2d5a 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -46,6 +46,7 @@ import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.hot.sdk.TangemHotSdk import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler +import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient import com.tangem.tap.common.log.TangemAppLoggerInitializer import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles import com.tangem.tap.proxy.AppStateHolder @@ -157,4 +158,6 @@ interface ApplicationEntryPoint { fun getTrackingContextProxy(): TrackingContextProxy fun getABTestsManager(): ABTestsManager + + fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index abbd2d80de..bf7f5f336a 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -66,6 +66,7 @@ import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler 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.appsflyer.AppsFlyerClient import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandler import com.tangem.tap.common.images.createCoilImageLoader import com.tangem.tap.common.log.TangemAppLoggerInitializer @@ -248,6 +249,9 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private val abTestsManager: ABTestsManager get() = entryPoint.getABTestsManager() + private val appsFlyerClientFactory: AppsFlyerClient.Factory + get() = entryPoint.getAppsFlyerClientFactory() + // endregion private val appScope = MainScope() @@ -421,7 +425,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. val factory = AnalyticsFactory() factory.addHandlerBuilder(AmplitudeAnalyticsHandler.Builder()) factory.addHandlerBuilder(FirebaseAnalyticsHandler.Builder()) - factory.addHandlerBuilder(AppsFlyerAnalyticsHandler.Builder()) + factory.addHandlerBuilder(AppsFlyerAnalyticsHandler.Builder(appsFlyerClientFactory)) factory.addFilter(oneTimeEventFilter) factory.addFilter(AppsFlyerEventFilter()) diff --git a/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt new file mode 100644 index 0000000000..2b3e47076b --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt @@ -0,0 +1,94 @@ +package com.tangem.tap.common.analytics.appsflyer + +import androidx.annotation.VisibleForTesting +import com.appsflyer.deeplink.DeepLink +import com.appsflyer.deeplink.DeepLinkListener +import com.appsflyer.deeplink.DeepLinkResult +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +@Singleton +class AppsFlyerDeepLinkListener @Inject constructor( + private val appsFlyerConversionStore: AppsFlyerConversionStore, + dispatchers: CoroutineDispatcherProvider, +) : DeepLinkListener { + + private val coroutineScope = CoroutineScope(dispatchers.io + SupervisorJob()) + private val mutex = Mutex() + + override fun onDeepLinking(p0: DeepLinkResult) { + when (p0.status) { + DeepLinkResult.Status.FOUND -> { + onDeepLinkFound(p0.deepLink) + } + DeepLinkResult.Status.NOT_FOUND -> { + Timber.i("No deep link found") + } + DeepLinkResult.Status.ERROR -> { + Timber.e("Deep link error: ${p0.error}") + } + } + } + + private fun onDeepLinkFound(deepLink: DeepLink) { + if (deepLink.deepLinkValue != REFERRAL_DEEP_LINK_VALUE) { + Timber.i("Ignoring deep link with value: ${deepLink.deepLinkValue ?: "null"}") + return + } + + val refcode = deepLink.getStringValue(REFCODE_PARAM_KEY) + val campaign = deepLink.getStringValue(CAMPAIGN_PARAM_KEY) + + @Suppress("NullableToStringCall") + Timber.i("refcode=$refcode\ncampaign=$campaign") + + if (!isValidParam(refcode)) { + Timber.e("Deep link conversion data is invalid") + return + } + + storeConversionData(refcode = refcode, campaign = campaign) + } + + @OptIn(ExperimentalContracts::class) + private fun isValidParam(value: String?): Boolean { + contract { + returns(true) implies (value != null) + } + + return value != null && value.isNotBlank() && !value.equals("null", ignoreCase = true) + } + + private fun storeConversionData(refcode: String, campaign: String?) { + coroutineScope.launch { + mutex.withLock { + appsFlyerConversionStore.store( + value = AppsFlyerConversionData(refcode = refcode, campaign = campaign), + ) + } + } + } + + companion object Companion { + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + const val REFERRAL_DEEP_LINK_VALUE = "referral" + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + const val REFCODE_PARAM_KEY = "deep_link_sub1" + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + const val CAMPAIGN_PARAM_KEY = "deep_link_sub2" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt index 231660ff27..27ced0a864 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt @@ -6,59 +6,36 @@ import com.appsflyer.AppsFlyerLib import com.appsflyer.attribution.AppsFlyerRequestListener import com.tangem.core.analytics.api.EventLogger import com.tangem.core.analytics.api.UserIdHolder +import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeepLinkListener import com.tangem.tap.common.analytics.handlers.firebase.UnderscoreAnalyticsEventConverter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext import timber.log.Timber interface AppsFlyerAnalyticsClient : EventLogger, UserIdHolder -internal class AppsFlyerClient( - private val context: Context, - key: String, +class AppsFlyerClient @AssistedInject constructor( + @Assisted apiKey: String, + @ApplicationContext private val context: Context, + appsFlyerDeepLinkListener: AppsFlyerDeepLinkListener, ) : AppsFlyerAnalyticsClient { private val appsFlyerLib: AppsFlyerLib = AppsFlyerLib.getInstance() private val eventConverter = UnderscoreAnalyticsEventConverter() - private val logEventListener = object : AppsFlyerRequestListener { - override fun onSuccess() { - Timber.tag("AppsFlyerClient").i("AppsFlyerRequestListener send") - } - - override fun onError(p0: Int, p1: String) { - Timber.tag("AppsFlyerClient").e("AppsFlyerRequestListener onError: $p0, $p1") - } - } init { - val conversionListener = object : AppsFlyerConversionListener { - override fun onConversionDataSuccess(p0: Map?) { - Timber.i("AppsFlyer conversion data success: ${p0.orEmpty()}") - } - override fun onConversionDataFail(p0: String?) { - Timber.e("AppsFlyer conversion data failure: ${p0.orEmpty()}") - } - override fun onAppOpenAttribution(p0: Map?) { - Timber.i("AppsFlyer app open attribution: ${p0.orEmpty()}") - } - override fun onAttributionFailure(p0: String?) { - Timber.e("AppsFlyer attribution failure: ${p0.orEmpty()}") - } - } - - appsFlyerLib.run { + with(appsFlyerLib) { setAppId(context.packageName) setDebugLog(true) - init(key, conversionListener, context) - Timber.i("Starting AppsFlyer SDK") - start(context, key, object : AppsFlyerRequestListener { - override fun onSuccess() { - Timber.i("AppsFlyer initialized successfully") - } + init(apiKey, ConversionListener, context) - override fun onError(p0: Int, p1: String) { - Timber.e("AppsFlyer initialization error: $p0, $p1") - } - }) + subscribeForDeepLink(appsFlyerDeepLinkListener) + + Timber.i("Starting AppsFlyer SDK") + start(context, apiKey, InitializationListener) Timber.i("AppsFlyer SDK started") } } @@ -77,7 +54,47 @@ internal class AppsFlyerClient( context, event, eventConverter.convertEventParams(params), - logEventListener, + LogEventListener, ) } + + private object ConversionListener : AppsFlyerConversionListener { + override fun onConversionDataSuccess(p0: Map?) { + Timber.i("AppsFlyer conversion data success: ${p0.orEmpty()}") + } + override fun onConversionDataFail(p0: String?) { + Timber.e("AppsFlyer conversion data failure: ${p0.orEmpty()}") + } + override fun onAppOpenAttribution(p0: Map?) { + Timber.i("AppsFlyer app open attribution: ${p0.orEmpty()}") + } + override fun onAttributionFailure(p0: String?) { + Timber.e("AppsFlyer attribution failure: ${p0.orEmpty()}") + } + } + + private object InitializationListener : AppsFlyerRequestListener { + override fun onSuccess() { + Timber.d("AppsFlyer initialized successfully") + } + + override fun onError(p0: Int, p1: String) { + Timber.e("AppsFlyer initialization error: $p0, $p1") + } + } + + private object LogEventListener : AppsFlyerRequestListener { + override fun onSuccess() { + Timber.tag("AppsFlyerClient").i("AppsFlyerRequestListener send") + } + + override fun onError(p0: Int, p1: String) { + Timber.tag("AppsFlyerClient").e("AppsFlyerRequestListener onError: $p0, $p1") + } + } + + @AssistedFactory + interface Factory { + fun create(apiKey: String): AppsFlyerClient + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsHandler.kt index 26675e0fef..40dfd9b37a 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsHandler.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsHandler.kt @@ -47,7 +47,10 @@ class AppsFlyerAnalyticsHandler( const val ID = "AppsFlyer" } - class Builder : AnalyticsHandlerBuilder { + internal class Builder( + private val appsFlyerClientFactory: AppsFlyerClient.Factory, + ) : AnalyticsHandlerBuilder { + init { Timber.tag("AppsFlyer").i("AppsFlyer Analytics Handler Builder created") } @@ -58,7 +61,7 @@ class AppsFlyerAnalyticsHandler( AppsFlyerLogClient(data.jsonConverter) } else { Timber.tag("AppsFlyer").i("AppsFlyer log disabled, real client created") - AppsFlyerClient(data.application, data.config.appsFlyerApiKey) + appsFlyerClientFactory.create(apiKey = data.config.appsFlyerApiKey) }, ) } diff --git a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt new file mode 100644 index 0000000000..d940d4fdfa --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt @@ -0,0 +1,120 @@ +package com.tangem.tap.common.analytics.appsflyer + +import com.appsflyer.deeplink.DeepLink +import com.appsflyer.deeplink.DeepLinkResult +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeepLinkListener.Companion.CAMPAIGN_PARAM_KEY +import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeepLinkListener.Companion.REFCODE_PARAM_KEY +import com.tangem.test.core.ProvideTestModels +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AppsFlyerDeepLinkListenerTest { + + private val appsFlyerConversionStore: AppsFlyerConversionStore = mockk(relaxUnitFun = true) + private val listener = AppsFlyerDeepLinkListener( + appsFlyerConversionStore = appsFlyerConversionStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @AfterEach + fun tearDown() { + clearMocks(appsFlyerConversionStore) + } + + @ParameterizedTest + @ProvideTestModels + fun onDeepLinking(model: OnDeepLinkingModel) = runTest { + listener.onDeepLinking(p0 = model.deepLinkResult) + + if (model.shouldStore) { + val value = AppsFlyerConversionData(refcode = SUCCESS_REFCODE, campaign = SUCCESS_CAMPAIGN) + coVerify { appsFlyerConversionStore.store(value = value) } + } else { + coVerify(inverse = true) { appsFlyerConversionStore.store(value = any()) } + } + } + + private fun provideTestModels(): List { + return listOf( + // DeepLinkResult.Status.NOT_FOUND + OnDeepLinkingModel( + deepLinkResult = DeepLinkResult(null, null), + shouldStore = false, + ), + // DeepLinkResult.Status.ERROR + OnDeepLinkingModel( + deepLinkResult = DeepLinkResult(null, DeepLinkResult.Error.NETWORK), + shouldStore = false, + ), + // DeepLinkResult.Status.FOUND + OnDeepLinkingModel( + deepLinkResult = createFoundDeepLink( + deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE, + refcode = SUCCESS_REFCODE, + campaign = SUCCESS_CAMPAIGN, + ), + shouldStore = true, + ), + OnDeepLinkingModel( + deepLinkResult = createFoundDeepLink( + deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE, + refcode = "", + campaign = SUCCESS_CAMPAIGN, + ), + shouldStore = false, + ), + OnDeepLinkingModel( + deepLinkResult = createFoundDeepLink( + deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE, + refcode = null, + campaign = SUCCESS_CAMPAIGN, + ), + shouldStore = false, + ), + OnDeepLinkingModel( + deepLinkResult = createFoundDeepLink(deepLinkValue = "some_other_deep_link_value"), + shouldStore = false, + ), + ) + } + + data class OnDeepLinkingModel( + val deepLinkResult: DeepLinkResult, + val shouldStore: Boolean, + ) + + private fun createFoundDeepLink( + deepLinkValue: String, + refcode: String? = null, + campaign: String? = null, + ): DeepLinkResult { + val deeplink = mockk { + every { this@mockk.deepLinkValue } returns deepLinkValue + every { this@mockk.getStringValue(REFCODE_PARAM_KEY) } returns refcode + every { this@mockk.getStringValue(CAMPAIGN_PARAM_KEY) } returns campaign + } + + return mockk { + every { this@mockk.status } returns DeepLinkResult.Status.FOUND + every { this@mockk.deepLink } returns deeplink + } + } + + private companion object { + const val SUCCESS_REFCODE = "valid_refcode" + const val SUCCESS_CAMPAIGN = "valid_campaign" + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt index 0e65ff47c9..dba595b6ff 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt @@ -12,6 +12,8 @@ object WalletIdBodyConverter { walletId = userWallet.walletId.stringValue, name = userWallet.name, walletType = WalletType.from(userWallet), + refcode = conversionData?.refcode, + campaign = conversionData?.campaign, cards = publicKeys?.map { publicKeyById -> CardInfoBody( cardId = publicKeyById.key, diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AppsFlyerConversionStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AppsFlyerConversionStoreModule.kt new file mode 100644 index 0000000000..d9222d32e3 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AppsFlyerConversionStoreModule.kt @@ -0,0 +1,21 @@ +package com.tangem.datasource.di + +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore +import com.tangem.datasource.local.appsflyer.DefaultAppsFlyerConversionStore +import com.tangem.datasource.local.preferences.AppPreferencesStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object AppsFlyerConversionStoreModule { + + @Provides + @Singleton + fun provideAppFlyerConversionStore(appPreferencesStore: AppPreferencesStore): AppsFlyerConversionStore { + return DefaultAppsFlyerConversionStore(appPreferencesStore = appPreferencesStore) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerConversionStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerConversionStore.kt new file mode 100644 index 0000000000..99fa70fc9b --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerConversionStore.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.local.appsflyer + +import com.tangem.domain.wallets.models.AppsFlyerConversionData + +interface AppsFlyerConversionStore { + + suspend fun get(): AppsFlyerConversionData? + + suspend fun store(value: AppsFlyerConversionData) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/ConversionDataConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/ConversionDataConverter.kt new file mode 100644 index 0000000000..4afca6f7b6 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/ConversionDataConverter.kt @@ -0,0 +1,21 @@ +package com.tangem.datasource.local.appsflyer + +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.utils.converter.TwoWayConverter + +internal object ConversionDataConverter : TwoWayConverter { + + override fun convert(value: AppsFlyerConversionData): ConversionDataDTO { + return ConversionDataDTO( + refcode = value.refcode, + campaign = value.campaign, + ) + } + + override fun convertBack(value: ConversionDataDTO): AppsFlyerConversionData { + return AppsFlyerConversionData( + refcode = value.refcode, + campaign = value.campaign, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerConversionStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerConversionStore.kt new file mode 100644 index 0000000000..50a7e53100 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerConversionStore.kt @@ -0,0 +1,42 @@ +package com.tangem.datasource.local.appsflyer + +import androidx.datastore.preferences.core.stringPreferencesKey +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull +import com.tangem.datasource.local.preferences.utils.storeObject +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import timber.log.Timber + +internal class DefaultAppsFlyerConversionStore( + private val appPreferencesStore: AppPreferencesStore, +) : AppsFlyerConversionStore { + + override suspend fun get(): AppsFlyerConversionData? { + val dto = appPreferencesStore.getObjectSyncOrNull(KEY) ?: return null + + return ConversionDataConverter.convertBack(value = dto).also { + Timber.i("Getting conversion data from store: $it") + } + } + + override suspend fun store(value: AppsFlyerConversionData) { + Timber.i("Storing conversion data to store: $value") + + val dto = ConversionDataConverter.convert(value) + + appPreferencesStore.storeObject(KEY, dto) + } + + private companion object { + + val KEY = stringPreferencesKey("APPS_FLYER_CONVERSION_DATA") + } +} + +@JsonClass(generateAdapter = true) +internal data class ConversionDataDTO( + @Json(name = "refcode") val refcode: String, + @Json(name = "campaign") val campaign: String?, +) \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt index 2b4e65a095..56153829bb 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt @@ -72,6 +72,8 @@ class UserTokensSaver( val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher).copy( walletName = userWallet.name, walletType = WalletType.from(userWallet), + refcode = conversionData?.refcode, + campaign = conversionData?.campaign, ) pushLegacy(userWalletId = userWalletId, response = enrichedResponse, onFailSend = onFailSend) diff --git a/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/AppsFlyerConversionData.kt b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/AppsFlyerConversionData.kt new file mode 100644 index 0000000000..4a8055e472 --- /dev/null +++ b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/AppsFlyerConversionData.kt @@ -0,0 +1,3 @@ +package com.tangem.domain.wallets.models + +data class AppsFlyerConversionData(val refcode: String, val campaign: String?) \ No newline at end of file From e45d91b15e5b32efa7e98a1327987c12e4f73d35 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 15 Jan 2026 11:56:24 +0300 Subject: [PATCH 4/9] Updated on 2026-08-14 --- .../converters/WalletIdBodyConverter.kt | 7 +- .../tangemTech/models/UserTokensResponse.kt | 2 + .../api/tangemTech/models/WalletIdBody.kt | 2 + .../converters/WalletIdBodyConverterTest.kt | 12 +- .../FetchWalletAccountsErrorHandler.kt | 23 +- .../FetchWalletAccountsErrorHandlerTest.kt | 44 +--- .../data/common/currency/UserTokensSaver.kt | 9 +- .../tangem/data/common/di/DataCommonModule.kt | 23 ++ .../wallet/DefaultWalletServerBinder.kt | 35 +++ .../data/common/wallet/WalletServerBinder.kt | 26 ++ .../common/currency/UserTokensSaverTest.kt | 15 +- .../wallet/DefaultWalletServerBinderTest.kt | 226 ++++++++++++++++++ .../data/wallets/DefaultWalletsRepository.kt | 14 +- .../data/wallets/di/WalletsDataModule.kt | 6 + .../wallets/DefaultWalletsRepositoryTest.kt | 12 + 15 files changed, 389 insertions(+), 67 deletions(-) create mode 100644 data/common/src/main/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinder.kt create mode 100644 data/common/src/main/kotlin/com/tangem/data/common/wallet/WalletServerBinder.kt create mode 100644 data/common/src/test/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinderTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt index dba595b6ff..8163c5ca35 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt @@ -4,10 +4,15 @@ import com.tangem.datasource.api.tangemTech.models.CardInfoBody import com.tangem.datasource.api.tangemTech.models.WalletIdBody import com.tangem.datasource.api.tangemTech.models.WalletType import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.models.AppsFlyerConversionData object WalletIdBodyConverter { - fun convert(userWallet: UserWallet, publicKeys: Map? = null): WalletIdBody { + fun convert( + userWallet: UserWallet, + conversionData: AppsFlyerConversionData? = null, + publicKeys: Map? = null, + ): WalletIdBody { return WalletIdBody( walletId = userWallet.walletId.stringValue, name = userWallet.name, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt index c9602c0247..c0edc1becf 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt @@ -17,6 +17,8 @@ data class UserTokensResponse( @Json(name = "notifyStatus") val notifyStatus: Boolean? = null, @Json(name = "name") val walletName: String? = null, @Json(name = "type") val walletType: WalletType? = null, + @Json(name = "ref") val refcode: String? = null, + @Json(name = "campaign") val campaign: String? = null, ) { @JsonClass(generateAdapter = true) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt index cb4bd5f2df..597380b6f7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt @@ -9,4 +9,6 @@ data class WalletIdBody( @Json(name = "name") val name: String, @Json(name = "type") val walletType: WalletType? = null, @Json(name = "cards") val cards: List? = null, + @Json(name = "ref") val refcode: String? = null, + @Json(name = "campaign") val campaign: String? = null, ) \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverterTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverterTest.kt index 299deb3a22..3ac09a3455 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverterTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverterTest.kt @@ -30,7 +30,11 @@ class WalletIdBodyConverterTest { ) // WHEN - val result = WalletIdBodyConverter.convert(userWallet, publicKeys) + val result = WalletIdBodyConverter.convert( + userWallet = userWallet, + conversionData = null, + publicKeys = publicKeys, + ) // THEN assertThat(result).isEqualTo( @@ -68,7 +72,11 @@ class WalletIdBodyConverterTest { val publicKeys = emptyMap() // WHEN - val result = WalletIdBodyConverter.convert(userWallet, publicKeys) + val result = WalletIdBodyConverter.convert( + userWallet = userWallet, + conversionData = null, + publicKeys = publicKeys, + ) // THEN assertThat(result).isEqualTo( diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt index 772a1dce68..266d845b10 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -4,22 +4,18 @@ import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher.FetchResult import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code import com.tangem.datasource.api.common.response.ETAG_HEADER import com.tangem.datasource.api.common.response.isNetworkError -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore -import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -27,26 +23,21 @@ import javax.inject.Singleton /** * Handles errors that occur during the fetching of wallet accounts * - * @property tangemTechApi API for network requests - * @property userWalletsStore provides access to user wallets storage * @property userTokensSaver saves user tokens to the storage * @property userTokensResponseStore provides access to user token responses. * @property defaultWalletAccountsResponseFactory creates [GetWalletAccountsResponse] from [UserTokensResponse] - * @property dispatchers dispatchers + * @property walletServerBinder binds user wallets on the server * * @see DefaultWalletAccountsFetcher * [REDACTED_AUTHOR] */ -@Suppress("LongParameterList") @Singleton internal class FetchWalletAccountsErrorHandler @Inject constructor( - private val tangemTechApi: TangemTechApi, - private val userWalletsStore: UserWalletsStore, private val userTokensSaver: UserTokensSaver, private val userTokensResponseStore: UserTokensResponseStore, private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory, - private val dispatchers: CoroutineDispatcherProvider, + private val walletServerBinder: WalletServerBinder, ) { /** @@ -119,13 +110,7 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( */ private suspend fun createWallet(userWalletId: UserWalletId): String? { - val userWallet = userWalletsStore.getSyncOrNull(key = userWalletId) ?: return null - - val creationResponse = withContext(dispatchers.io) { - tangemTechApi.createWallet( - body = WalletIdBodyConverter.convert(userWallet), - ) - } + val creationResponse = walletServerBinder.bind(userWalletId) return if (creationResponse is ApiResponse.Success && creationResponse.code == Code.CREATED) { creationResponse.headers[ETAG_HEADER]?.firstOrNull() diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt index 51d9c2ad3f..80d6803bf8 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt @@ -4,23 +4,22 @@ import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.converter.createWalletAccountDTO import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code import com.tangem.datasource.api.common.response.ETAG_HEADER import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.datasource.api.tangemTech.models.WalletIdBody -import com.tangem.datasource.api.tangemTech.models.WalletType import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore -import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.* +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -33,18 +32,16 @@ import org.junit.jupiter.api.TestInstance class FetchWalletAccountsErrorHandlerTest { private val tangemTechApi: TangemTechApi = mockk() - private val userWalletsStore: UserWalletsStore = mockk() + private val walletServerBinder: WalletServerBinder = mockk() private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true) private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk() private val handler = FetchWalletAccountsErrorHandler( - tangemTechApi = tangemTechApi, - userWalletsStore = userWalletsStore, + walletServerBinder = walletServerBinder, userTokensSaver = userTokensSaver, userTokensResponseStore = userTokensResponseStore, defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory, - dispatchers = TestingCoroutineDispatcherProvider(), ) private val pushWalletAccounts: suspend (List, String) -> GetWalletAccountsResponse = @@ -55,7 +52,7 @@ class FetchWalletAccountsErrorHandlerTest { fun setupEach() { clearMocks( tangemTechApi, - userWalletsStore, + walletServerBinder, userTokensSaver, userTokensResponseStore, defaultWalletAccountsResponseFactory, @@ -112,22 +109,7 @@ class FetchWalletAccountsErrorHandlerTest { code = Code.CREATED, ) - val walletName = "Wallet" - val userWallet = mockk { - every { this@mockk.walletId } returns userWalletId - every { this@mockk.name } returns walletName - } - coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet - - coEvery { - tangemTechApi.createWallet( - WalletIdBody( - walletId = userWalletId.stringValue, - name = walletName, - walletType = WalletType.COLD, - ), - ) - } returns apiResponse + coEvery { walletServerBinder.bind(userWalletId) } returns apiResponse coEvery { pushWalletAccounts(listOf(accountDTO), eTagValue) } returns savedAccountsResponse // Act @@ -142,13 +124,7 @@ class FetchWalletAccountsErrorHandlerTest { // Assert coVerify { userTokensSaver.pushWithRetryer(userWalletId, response = savedAccountsResponse.toUserTokensResponse()) - tangemTechApi.createWallet( - WalletIdBody( - walletId = userWalletId.stringValue, - name = walletName, - walletType = WalletType.COLD, - ), - ) + walletServerBinder.bind(userWalletId) pushWalletAccounts(listOf(accountDTO), eTagValue) storeWalletAccounts(userWalletId, savedAccountsResponse) } diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt index 56153829bb..e0ae509ee6 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt @@ -2,13 +2,14 @@ package com.tangem.data.common.currency import com.tangem.data.common.api.safeApiCall import com.tangem.data.common.tokens.UserTokensBackwardCompatibility +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.isNetworkError import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.WalletType +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles @@ -27,6 +28,8 @@ class UserTokensSaver( private val userTokensResponseStore: UserTokensResponseStore, private val dispatchers: CoroutineDispatcherProvider, private val addressesEnricher: UserTokensResponseAddressesEnricher, + private val walletServerBinder: WalletServerBinder, + private val appsFlyerConversionStore: AppsFlyerConversionStore, private val accountsFeatureToggles: AccountsFeatureToggles, private val pushTokensRetryerPool: RetryerPool, ) { @@ -69,6 +72,8 @@ class UserTokensSaver( pushNew(userWallet = userWallet, response = enrichedResponse, onFailSend = onFailSend) } else { + val conversionData = appsFlyerConversionStore.get() + val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher).copy( walletName = userWallet.name, walletType = WalletType.from(userWallet), @@ -116,7 +121,7 @@ class UserTokensSaver( apiResponse.cause.isNetworkError(ApiResponseError.HttpException.Code.NOT_FOUND) if (isWalletNotFound) { - tangemTechApi.createWallet(body = WalletIdBodyConverter.convert(userWallet)).bind() + walletServerBinder.bind(userWallet).bind() tangemTechApi.saveTokens( userId = userWallet.walletId.stringValue, diff --git a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt index 48751fa834..9cc64b8160 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt @@ -7,7 +7,10 @@ import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.* import com.tangem.data.common.quote.DefaultQuotesFetcher import com.tangem.data.common.quote.QuotesFetcher +import com.tangem.data.common.wallet.DefaultWalletServerBinder +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore @@ -72,6 +75,8 @@ internal object DataCommonModule { userTokensResponseStore: UserTokensResponseStore, dispatchers: CoroutineDispatcherProvider, addressesEnricher: UserTokensResponseAddressesEnricher, + walletServerBinder: WalletServerBinder, + appsFlyerConversionStore: AppsFlyerConversionStore, accountsFeatureToggles: AccountsFeatureToggles, ): UserTokensSaver { return UserTokensSaver( @@ -84,6 +89,8 @@ internal object DataCommonModule { pushTokensRetryerPool = RetryerPool( coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default), ), + walletServerBinder = walletServerBinder, + appsFlyerConversionStore = appsFlyerConversionStore, ) } @@ -98,4 +105,20 @@ internal object DataCommonModule { fun provideETagsStore(appPreferencesStore: AppPreferencesStore): ETagsStore { return DefaultETagsStore(appPreferencesStore = appPreferencesStore) } + + @Provides + @Singleton + fun provideWalletServerBinder( + userWalletsStore: UserWalletsStore, + appsFlyerConversionStore: AppsFlyerConversionStore, + tangemTechApi: TangemTechApi, + dispatchers: CoroutineDispatcherProvider, + ): WalletServerBinder { + return DefaultWalletServerBinder( + userWalletsStore = userWalletsStore, + appsFlyerConversionStore = appsFlyerConversionStore, + tangemTechApi = tangemTechApi, + dispatchers = dispatchers, + ) + } } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinder.kt b/data/common/src/main/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinder.kt new file mode 100644 index 0000000000..376f29fb84 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinder.kt @@ -0,0 +1,35 @@ +package com.tangem.data.common.wallet + +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext + +internal class DefaultWalletServerBinder( + private val userWalletsStore: UserWalletsStore, + private val appsFlyerConversionStore: AppsFlyerConversionStore, + private val tangemTechApi: TangemTechApi, + private val dispatchers: CoroutineDispatcherProvider, +) : WalletServerBinder { + + override suspend fun bind(userWalletId: UserWalletId): ApiResponse? { + val userWallet = userWalletsStore.getSyncOrNull(key = userWalletId) ?: return null + + return bind(userWallet) + } + + override suspend fun bind(userWallet: UserWallet): ApiResponse { + val conversionData = appsFlyerConversionStore.get() + + return withContext(dispatchers.io) { + tangemTechApi.createWallet( + body = WalletIdBodyConverter.convert(userWallet, conversionData), + ) + } + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/wallet/WalletServerBinder.kt b/data/common/src/main/kotlin/com/tangem/data/common/wallet/WalletServerBinder.kt new file mode 100644 index 0000000000..6aeb5b8a1e --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/wallet/WalletServerBinder.kt @@ -0,0 +1,26 @@ +package com.tangem.data.common.wallet + +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId + +/** + + * +[REDACTED_AUTHOR] + */ +interface WalletServerBinder { + + /** + * Binds a wallet to the server using its [userWalletId]. + * Returns an [ApiResponse] indicating the result of the binding operation, + * or null if the binding was not performed. + */ + suspend fun bind(userWalletId: UserWalletId): ApiResponse? + + /** + * Binds a wallet to the server using the provided [userWallet]. + * Returns an [ApiResponse] indicating the result of the binding operation. + */ + suspend fun bind(userWallet: UserWallet): ApiResponse +} \ No newline at end of file diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt index bc51068bff..64fe198777 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt @@ -1,10 +1,12 @@ package com.tangem.data.common.currency +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.WalletType +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles @@ -27,6 +29,8 @@ class UserTokensSaverTest { private val accountsFeatureToggles = mockk { every { this@mockk.isFeatureEnabled } returns true } + private val walletServerBinder: WalletServerBinder = mockk() + private val appsFlyerConversionStore: AppsFlyerConversionStore = mockk() private val userTokensSaver: UserTokensSaver = UserTokensSaver( tangemTechApi = tangemTechApi, @@ -34,13 +38,21 @@ class UserTokensSaverTest { userTokensResponseStore = userTokensResponseStore, dispatchers = TestingCoroutineDispatcherProvider(), addressesEnricher = enricher, + walletServerBinder = walletServerBinder, + appsFlyerConversionStore = appsFlyerConversionStore, accountsFeatureToggles = accountsFeatureToggles, pushTokensRetryerPool = mockk(), ) @BeforeEach fun resetMocks() { - clearMocks(tangemTechApi, userWalletsStore, userTokensResponseStore, enricher) + clearMocks( + tangemTechApi, + userWalletsStore, + userTokensResponseStore, + enricher, + walletServerBinder, + ) } @Test @@ -109,7 +121,6 @@ class UserTokensSaverTest { coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet coEvery { enricher(userWalletId, response) } returns enrichedResponse coEvery { tangemTechApi.saveTokens(any(), any()) } returns ApiResponse.Error(error) as ApiResponse - coEvery { tangemTechApi.createWallet(body = any()) } returns ApiResponse.Error(error) as ApiResponse // WHEN userTokensSaver.push( diff --git a/data/common/src/test/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinderTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinderTest.kt new file mode 100644 index 0000000000..620f841b4a --- /dev/null +++ b/data/common/src/test/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinderTest.kt @@ -0,0 +1,226 @@ +package com.tangem.data.common.wallet + +import com.google.common.truth.Truth +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.WalletIdBody +import com.tangem.datasource.api.tangemTech.models.WalletType +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultWalletServerBinderTest { + + private val userWalletsStore: UserWalletsStore = mockk() + private val appsFlyerConversionStore: AppsFlyerConversionStore = mockk() + private val tangemTechApi: TangemTechApi = mockk() + private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider() + + private val binder = DefaultWalletServerBinder( + userWalletsStore = userWalletsStore, + appsFlyerConversionStore = appsFlyerConversionStore, + tangemTechApi = tangemTechApi, + dispatchers = dispatchers + ) + + private val userWalletId = UserWalletId("011") + private val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.name } returns "Wallet" + } + private val conversionData = AppsFlyerConversionData(refcode = "refcode", campaign = "campaign") + + @AfterEach + fun tearDown() { + clearMocks(userWalletsStore, appsFlyerConversionStore, tangemTechApi) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class BindUserWalletId { + + @Test + fun `bind successfully`() = runTest { + val requestBody = WalletIdBody( + walletId = userWalletId.stringValue, + name = "Wallet", + walletType = WalletType.COLD, + refcode = "refcode", + campaign = "campaign", + ) + val apiResponse = ApiResponse.Success(Unit) + + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet + coEvery { appsFlyerConversionStore.get() } returns conversionData + coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse + + val actual = binder.bind(userWalletId = userWalletId) + + Truth.assertThat(actual).isEqualTo(apiResponse) + + coVerifyOrder { + userWalletsStore.getSyncOrNull(userWalletId) + appsFlyerConversionStore.get() + tangemTechApi.createWallet(requestBody) + } + } + + @Test + fun `bind will skipped if userWalletsStore returns null`() = runTest { + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns null + + val actual = binder.bind(userWalletId = userWalletId) + + Truth.assertThat(actual).isEqualTo(null) + + coVerifyOrder { + userWalletsStore.getSyncOrNull(userWalletId) + } + + coVerify(inverse = true) { + appsFlyerConversionStore.get() + tangemTechApi.createWallet(any()) + } + } + + @Test + fun `bind successfully if appsFlyerConversionStore returns null`() = runTest { + val requestBody = WalletIdBody( + walletId = userWalletId.stringValue, + name = "Wallet", + walletType = WalletType.COLD, + ) + val apiResponse = ApiResponse.Success(Unit) + + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet + coEvery { appsFlyerConversionStore.get() } returns null + coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse + + val actual = binder.bind(userWalletId = userWalletId) + + Truth.assertThat(actual).isEqualTo(apiResponse) + + coVerifyOrder { + userWalletsStore.getSyncOrNull(userWalletId) + appsFlyerConversionStore.get() + tangemTechApi.createWallet(requestBody) + } + } + + @Test + fun `bind returns error if request is failed`() = runTest { + val requestBody = WalletIdBody( + walletId = userWalletId.stringValue, + name = "Wallet", + walletType = WalletType.COLD, + refcode = "refcode", + campaign = "campaign", + ) + val apiResponse = ApiResponse.Error(ApiResponseError.TimeoutException()) as ApiResponse + + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet + coEvery { appsFlyerConversionStore.get() } returns conversionData + coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse + + val actual = binder.bind(userWalletId = userWalletId) + + Truth.assertThat(actual).isEqualTo(apiResponse) + + coVerifyOrder { + userWalletsStore.getSyncOrNull(userWalletId) + appsFlyerConversionStore.get() + tangemTechApi.createWallet(requestBody) + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class BindUserWallet { + + @Test + fun `bind successfully`() = runTest { + val requestBody = WalletIdBody( + walletId = userWalletId.stringValue, + name = "Wallet", + walletType = WalletType.COLD, + refcode = "refcode", + campaign = "campaign", + ) + val apiResponse = ApiResponse.Success(Unit) + + coEvery { appsFlyerConversionStore.get() } returns conversionData + coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse + + val actual = binder.bind(userWallet = userWallet) + + Truth.assertThat(actual).isEqualTo(apiResponse) + + coVerifyOrder { + appsFlyerConversionStore.get() + tangemTechApi.createWallet(requestBody) + } + } + + @Test + fun `bind successfully if appsFlyerConversionStore returns null`() = runTest { + val requestBody = WalletIdBody( + walletId = userWalletId.stringValue, + name = "Wallet", + walletType = WalletType.COLD, + ) + val apiResponse = ApiResponse.Success(Unit) + + coEvery { appsFlyerConversionStore.get() } returns null + coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse + + val actual = binder.bind(userWallet = userWallet) + + Truth.assertThat(actual).isEqualTo(apiResponse) + + coVerifyOrder { + appsFlyerConversionStore.get() + tangemTechApi.createWallet(requestBody) + } + } + + @Test + fun `bind returns error if request is failed`() = runTest { + val requestBody = WalletIdBody( + walletId = userWalletId.stringValue, + name = "Wallet", + walletType = WalletType.COLD, + refcode = "refcode", + campaign = "campaign", + ) + val apiResponse = ApiResponse.Error(ApiResponseError.TimeoutException()) as ApiResponse + + coEvery { appsFlyerConversionStore.get() } returns conversionData + coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse + + val actual = binder.bind(userWallet = userWallet) + + Truth.assertThat(actual).isEqualTo(apiResponse) + + coVerifyOrder { + appsFlyerConversionStore.get() + tangemTechApi.createWallet(requestBody) + } + } + } +} \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt index def9a96402..ab7d7a4c66 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt @@ -3,6 +3,7 @@ package com.tangem.data.wallets import arrow.core.Either import arrow.core.left import arrow.core.right +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.data.wallets.converters.UserWalletRemoteInfoConverter import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.response.ApiResponse @@ -14,6 +15,7 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter import com.tangem.datasource.api.tangemTech.models.* import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO.Status +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys @@ -46,6 +48,8 @@ internal class DefaultWalletsRepository( private val seedPhraseNotificationVisibilityStore: RuntimeStateStore, private val dispatchers: CoroutineDispatcherProvider, private val authProvider: AuthProvider, + private val walletServerBinder: WalletServerBinder, + private val appsFlyerConversionStore: AppsFlyerConversionStore, private val accountsFeatureToggles: AccountsFeatureToggles, private val moshi: com.squareup.moshi.Moshi, ) : WalletsRepository { @@ -250,13 +254,7 @@ internal class DefaultWalletsRepository( } override suspend fun createWallet(userWalletId: UserWalletId) { - runCatching(dispatchers.io) { - val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) - - tangemTechApi.createWallet( - body = WalletIdBodyConverter.convert(userWallet = userWallet), - ) - } + walletServerBinder.bind(userWalletId) } override suspend fun rejectSeedPhraseSecondNotification(userWalletId: UserWalletId) { @@ -413,10 +411,12 @@ internal class DefaultWalletsRepository( associateApplicationIdWithWallets().getOrThrow() } } else { + val conversionData = appsFlyerConversionStore.get() val publicKeys = authProvider.getCardsPublicKeys() val walletsBody = wallets.map { userWallet -> WalletIdBodyConverter.convert( userWallet = userWallet, + conversionData = conversionData, publicKeys = if (userWallet is UserWallet.Cold) { publicKeys.filterKeys { userWallet.cardsInWallet.contains(it) diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt b/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt index a1e870a3e1..3ba821417e 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt @@ -1,6 +1,7 @@ package com.tangem.data.wallets.di import com.squareup.moshi.Moshi +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.data.wallets.DefaultWalletNamesMigrationRepository import com.tangem.data.wallets.DefaultWalletsRepository import com.tangem.data.wallets.cold.DefaultColdMapDerivationsRepository @@ -10,6 +11,7 @@ import com.tangem.data.wallets.hot.DefaultHotWalletAccessCodeAttemptsRepository import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.userwallet.UserWalletsStore @@ -40,6 +42,8 @@ internal object WalletsDataModule { userWalletsStore: UserWalletsStore, dispatchers: CoroutineDispatcherProvider, authProvider: AuthProvider, + walletServerBinder: WalletServerBinder, + appsFlyerConversionStore: AppsFlyerConversionStore, accountsFeatureToggles: AccountsFeatureToggles, @NetworkMoshi moshi: Moshi, ): WalletsRepository { @@ -50,6 +54,8 @@ internal object WalletsDataModule { seedPhraseNotificationVisibilityStore = RuntimeStateStore(defaultValue = emptyMap()), dispatchers = dispatchers, authProvider = authProvider, + walletServerBinder = walletServerBinder, + appsFlyerConversionStore = appsFlyerConversionStore, accountsFeatureToggles = accountsFeatureToggles, moshi = moshi, ) diff --git a/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt b/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt index 80b3e8d659..22bbfbdf5c 100644 --- a/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt +++ b/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import com.google.common.truth.Truth.assertThat import com.squareup.moshi.Moshi +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError.HttpException @@ -11,6 +12,7 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.PromocodeActivationBody import com.tangem.datasource.api.tangemTech.models.PromocodeActivationResponse import com.tangem.datasource.api.tangemTech.models.WalletResponse +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles @@ -38,12 +40,16 @@ class DefaultWalletsRepositoryTest { ) private lateinit var tangemTechApi: TangemTechApi private lateinit var dispatchers: CoroutineDispatcherProvider + private lateinit var walletServerBinder: WalletServerBinder + private lateinit var appsFlyerConversionStore: AppsFlyerConversionStore private val testWalletId = UserWalletId("1234567890abcdef") @Before fun setup() { tangemTechApi = mockk() + walletServerBinder = mockk() + appsFlyerConversionStore = mockk() dispatchers = TestingCoroutineDispatcherProvider() repository = DefaultWalletsRepository( appPreferencesStore = appPreferenceStore, @@ -52,6 +58,8 @@ class DefaultWalletsRepositoryTest { seedPhraseNotificationVisibilityStore = mockk(), dispatchers = dispatchers, authProvider = mockk(), + walletServerBinder = walletServerBinder, + appsFlyerConversionStore = appsFlyerConversionStore, accountsFeatureToggles = mockk(), moshi = mockk(), ) @@ -218,10 +226,14 @@ class DefaultWalletsRepositoryTest { seedPhraseNotificationVisibilityStore = mockk(), dispatchers = dispatchers, authProvider = authProvider, + walletServerBinder = walletServerBinder, + appsFlyerConversionStore = appsFlyerConversionStore, accountsFeatureToggles = accountsFeatureToggles, moshi = mockk(), ) + coEvery { appsFlyerConversionStore.get() } returns null + coEvery { tangemTechApi.associateApplicationIdWithWallets( eq(applicationId), From fc223990a4f1090b86622671bf1e02cf12fdf7e8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 15 Jan 2026 15:20:48 +0300 Subject: [PATCH 5/9] Updated on 2026-08-14 --- .../common/routing/deeplink/DeeplinkConst.kt | 2 + .../api/tangemTech/TangemTechApi.kt | 5 + .../models/BindWalletsByReferralCodeBody.kt | 11 ++ .../wallets/DefaultWalletsPromoRepository.kt | 104 ++++++++++++++++++ .../data/wallets/di/WalletsDataModule.kt | 20 ++++ .../repository/WalletsPromoRepository.kt | 15 +++ .../usecase/BindRefcodeWithWalletUseCase.kt | 75 +++++++++++++ .../wallet/child/wallet/model/WalletModel.kt | 6 + .../deeplink/DefaultPromoDeeplinkHandler.kt | 14 +++ .../DefaultPromoDeeplinkHandlerTest.kt | 18 +++ 10 files changed, 270 insertions(+) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/BindWalletsByReferralCodeBody.kt create mode 100644 data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsPromoRepository.kt create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsPromoRepository.kt create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/BindRefcodeWithWalletUseCase.kt diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt index 575be52e80..630b68ded8 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt @@ -12,5 +12,7 @@ object DeeplinkConst { const val DERIVATION_PATH_KEY = "derivation_path" const val TRANSACTION_ID_KEY = "transaction_id" const val PROMO_CODE_KEY = "promo_code" + const val REF_KEY = "ref" + const val CAMPAIGN_KEY = "campaign" const val NAME_KEY = "name" } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index aec86d12d2..a3b38cb1f0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -56,6 +56,7 @@ interface TangemTechApi { @Body userTokens: UserTokensResponse, ): ApiResponse + // region Referral /** Returns referral status by [walletId] */ @GET("v1/referral/{walletId}") suspend fun getReferralStatus(@Path("walletId") walletId: String): ApiResponse @@ -64,6 +65,10 @@ interface TangemTechApi { @POST("v1/referral") suspend fun startReferral(@Body startReferralBody: StartReferralBody): ApiResponse + @POST("v1/referral/bind-wallets-by-code") + suspend fun bindWalletsByReferralCode(@Body body: BindWalletsByReferralCodeBody): ApiResponse + // endregion + @GET("v1/quotes") suspend fun getQuotes( @Query("currencyId") currencyId: String, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/BindWalletsByReferralCodeBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/BindWalletsByReferralCodeBody.kt new file mode 100644 index 0000000000..1eac85742a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/BindWalletsByReferralCodeBody.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class BindWalletsByReferralCodeBody( + @Json(name = "walletIds") val walletIds: List, + @Json(name = "referralCode") val refcode: String, + @Json(name = "utmCampaign") val campaign: String? = null, +) \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsPromoRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsPromoRepository.kt new file mode 100644 index 0000000000..23738373d2 --- /dev/null +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsPromoRepository.kt @@ -0,0 +1,104 @@ +package com.tangem.data.wallets + +import androidx.datastore.preferences.core.stringPreferencesKey +import arrow.core.Option +import arrow.core.toOption +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.BindWalletsByReferralCodeBody +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull +import com.tangem.datasource.local.preferences.utils.storeObject +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.domain.wallets.repository.WalletsPromoRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching +import kotlinx.coroutines.withContext +import timber.log.Timber + +internal class DefaultWalletsPromoRepository( + private val appPreferencesStore: AppPreferencesStore, + private val tangemTechApi: TangemTechApi, + private val userWalletsStore: UserWalletsStore, + private val appsFlyerConversionStore: AppsFlyerConversionStore, + private val dispatchers: CoroutineDispatcherProvider, +) : WalletsPromoRepository { + + override suspend fun getConversionData(): Option { + return runSuspendCatching { appsFlyerConversionStore.get() }.getOrNull().toOption() + } + + override suspend fun saveConversionData(refcode: String, campaign: String?) { + val data = AppsFlyerConversionData(refcode = refcode, campaign = campaign) + + appsFlyerConversionStore.store(data) + } + + override suspend fun bindRefcodeWithWallets(refcode: String, campaign: String?) = withContext(dispatchers.io) { + val walletIds = userWalletsStore.userWalletsSync.map { it.walletId.stringValue } + + val result = tangemTechApi.bindWalletsByReferralCode( + body = BindWalletsByReferralCodeBody( + walletIds = walletIds, + refcode = refcode, + campaign = campaign, + ), + ) + + val bindingData = ReferralWalletsBindingData( + refcode = refcode, + campaign = campaign, + isDone = result is ApiResponse.Success, + ) + + appPreferencesStore.storeObject(key = REFERRAL_WALLETS_BINDING_DATA_KEY, value = bindingData) + + result.getOrThrow() + } + + override suspend fun bindSavedRefcodeWithWallets(): AppsFlyerConversionData { + val bindingData = appPreferencesStore.getObjectSyncOrNull( + key = REFERRAL_WALLETS_BINDING_DATA_KEY, + ) + + if (bindingData == null) { + val exception = IllegalStateException("No saved referral wallets binding data found") + Timber.e(exception) + throw exception + } + + val conversionData = AppsFlyerConversionData( + refcode = bindingData.refcode, + campaign = bindingData.campaign, + ) + + if (bindingData.isDone) { + Timber.i("Referral code ${bindingData.refcode} already bound with wallets") + return conversionData + } + + bindRefcodeWithWallets( + refcode = bindingData.refcode, + campaign = bindingData.campaign, + ) + + return conversionData + } + + @JsonClass(generateAdapter = true) + private data class ReferralWalletsBindingData( + @Json(name = "refcode") val refcode: String, + @Json(name = "campaign") val campaign: String?, + @Json(name = "done") val isDone: Boolean, + ) + + private companion object { + + val REFERRAL_WALLETS_BINDING_DATA_KEY by lazy { stringPreferencesKey(name = "referralWalletsBindingData") } + } +} \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt b/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt index 3ba821417e..5023a6099e 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt @@ -3,6 +3,7 @@ package com.tangem.data.wallets.di import com.squareup.moshi.Moshi import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.data.wallets.DefaultWalletNamesMigrationRepository +import com.tangem.data.wallets.DefaultWalletsPromoRepository import com.tangem.data.wallets.DefaultWalletsRepository import com.tangem.data.wallets.cold.DefaultColdMapDerivationsRepository import com.tangem.data.wallets.derivations.DefaultDerivationsRepository @@ -21,6 +22,7 @@ import com.tangem.domain.wallets.derivations.DerivationsRepository import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.repository.WalletNamesMigrationRepository +import com.tangem.domain.wallets.repository.WalletsPromoRepository import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Binds @@ -66,6 +68,24 @@ internal object WalletsDataModule { fun provideMigrateNamesRepository(appPreferencesStore: AppPreferencesStore): WalletNamesMigrationRepository { return DefaultWalletNamesMigrationRepository(appPreferencesStore) } + + @Provides + @Singleton + fun provideWalletsPromoRepository( + appPreferencesStore: AppPreferencesStore, + tangemTechApi: TangemTechApi, + userWalletsStore: UserWalletsStore, + appsFlyerConversionStore: AppsFlyerConversionStore, + dispatchers: CoroutineDispatcherProvider, + ): WalletsPromoRepository { + return DefaultWalletsPromoRepository( + appPreferencesStore = appPreferencesStore, + tangemTechApi = tangemTechApi, + userWalletsStore = userWalletsStore, + appsFlyerConversionStore = appsFlyerConversionStore, + dispatchers = dispatchers, + ) + } } @Module diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsPromoRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsPromoRepository.kt new file mode 100644 index 0000000000..e07fb8e436 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsPromoRepository.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.wallets.repository + +import arrow.core.Option +import com.tangem.domain.wallets.models.AppsFlyerConversionData + +interface WalletsPromoRepository { + + suspend fun getConversionData(): Option + + suspend fun saveConversionData(refcode: String, campaign: String?) + + suspend fun bindRefcodeWithWallets(refcode: String, campaign: String?) + + suspend fun bindSavedRefcodeWithWallets(): AppsFlyerConversionData +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/BindRefcodeWithWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/BindRefcodeWithWalletUseCase.kt new file mode 100644 index 0000000000..ec454d0aa9 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/BindRefcodeWithWalletUseCase.kt @@ -0,0 +1,75 @@ +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.domain.wallets.repository.WalletsPromoRepository +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject + +class BindRefcodeWithWalletUseCase @Inject constructor( + private val walletsPromoRepository: WalletsPromoRepository, +) { + + private val mutex = Mutex() + + suspend operator fun invoke(refcode: String, campaign: String?): Either = either { + ensure(refcode.isNotBlank()) { Error.InvalidRefcode } + + mutex.withLock { + checkSavedRefcode() + + bindRefcode(refcode = refcode, campaign = campaign) + + saveConversionData(refcode = refcode, campaign = campaign) + } + } + + suspend fun retry(): Either = either { + mutex.withLock { + val conversionData = tryBindRefcodeAgain() + + saveConversionData(refcode = conversionData.refcode, campaign = conversionData.campaign) + } + } + + private suspend fun Raise.checkSavedRefcode() { + walletsPromoRepository.getConversionData().onSome { + raise(Error.RefcodeAlreadySaved) + } + } + + private suspend fun Raise.bindRefcode(refcode: String, campaign: String?) { + catch( + block = { walletsPromoRepository.bindRefcodeWithWallets(refcode, campaign) }, + catch = { raise(Error.DataError(it)) }, + ) + } + + private suspend fun Raise.tryBindRefcodeAgain(): AppsFlyerConversionData { + return catch( + block = { walletsPromoRepository.bindSavedRefcodeWithWallets() }, + catch = { raise(Error.DataError(it)) }, + ) + } + + private suspend fun Raise.saveConversionData(refcode: String, campaign: String?) { + catch( + block = { walletsPromoRepository.saveConversionData(refcode, campaign) }, + catch = { raise(Error.DataError(it)) }, + ) + } + + sealed interface Error { + + data object InvalidRefcode : Error + + data object RefcodeAlreadySaved : Error + + data class DataError(val throwable: Throwable) : Error + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index ba456f80f5..dc160b2505 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -104,6 +104,7 @@ internal class WalletModel @Inject constructor( private val singleAccountListSupplier: SingleAccountListSupplier, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val feedFeatureToggle: FeedFeatureToggle, + private val bindRefcodeWithWalletUseCase: BindRefcodeWithWalletUseCase, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -138,6 +139,11 @@ internal class WalletModel @Inject constructor( enableNotificationsIfNeeded() clickIntents.initialize(innerWalletRouter, modelScope) + + modelScope.launch { + bindRefcodeWithWalletUseCase.retry() + .onLeft { Timber.e("Failed to bind refcode with wallets: $it") } + } } fun onResume() { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt index 5c151cb3ef..2c0d3c82e8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt @@ -1,6 +1,7 @@ package com.tangem.feature.wallet.deeplink import com.tangem.blockchain.common.Blockchain +import com.tangem.common.routing.deeplink.DeeplinkConst import com.tangem.common.routing.deeplink.DeeplinkConst.PROMO_CODE_KEY import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.GlobalUiMessageSender @@ -19,6 +20,7 @@ import com.tangem.domain.wallets.PromoCodeActivationResult import com.tangem.domain.wallets.PromoCodeActivationResult.* import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import com.tangem.domain.wallets.usecase.ActivateBitcoinPromocodeUseCase +import com.tangem.domain.wallets.usecase.BindRefcodeWithWalletUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.feature.wallet.deeplink.analytics.PromoActivationAnalytics import com.tangem.feature.wallet.impl.R @@ -44,10 +46,14 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val activateBitcoinPromocodeUseCase: ActivateBitcoinPromocodeUseCase, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val bindRefcodeWithWalletUseCase: BindRefcodeWithWalletUseCase, private val analyticsEventsHandler: AnalyticsEventHandler, private val dispatchers: CoroutineDispatcherProvider, ) : PromoDeeplinkHandler { + private val refcode: String? = queryParams[DeeplinkConst.REF_KEY] + private val campaign: String? = queryParams[DeeplinkConst.CAMPAIGN_KEY] + init { analyticsEventsHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart()) val promoCode = queryParams[PROMO_CODE_KEY].orEmpty() @@ -122,6 +128,14 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( Timber.tag(LOG_TAG).d( "Start activation promoCode ${promoCode.mask()} address ${bitcoinAddress.mask()}", ) + + if (refcode != null) { + launch { + bindRefcodeWithWalletUseCase(refcode = refcode, campaign = campaign) + .onLeft { Timber.e("Failed to bind refcode with wallets: $it") } + } + } + activatePromoCode(bitcoinAddress = bitcoinAddress, promoCode = promoCode) } else { uiMessageSender.send(GlobalLoadingMessage(false)) diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt index b6844fd970..126be8d9a7 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt @@ -25,6 +25,7 @@ import com.tangem.domain.wallets.PromoCodeActivationResult import com.tangem.domain.wallets.models.GetUserWalletError import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import com.tangem.domain.wallets.usecase.ActivateBitcoinPromocodeUseCase +import com.tangem.domain.wallets.usecase.BindRefcodeWithWalletUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.feature.wallet.deeplink.DefaultPromoDeeplinkHandler import com.tangem.feature.wallet.deeplink.analytics.PromoActivationAnalytics @@ -66,6 +67,9 @@ class DefaultPromoDeeplinkHandlerTest { @MockK private lateinit var analyticsEventHandler: AnalyticsEventHandler + @MockK + private lateinit var bindRefcodeWithWalletUseCase: BindRefcodeWithWalletUseCase + private lateinit var messages: MutableList @Before @@ -105,6 +109,7 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -137,6 +142,7 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -168,6 +174,7 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -205,6 +212,7 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -246,6 +254,7 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -362,6 +371,7 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -393,6 +403,7 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -433,6 +444,7 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -494,6 +506,7 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -546,6 +559,7 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -604,6 +618,7 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -661,6 +676,7 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -714,6 +730,7 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -767,6 +784,7 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) From 9fd13fdc4676039369ce6ecaa43ba5c68c448508 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 21 Jan 2026 14:21:10 +0400 Subject: [PATCH 6/9] Updated on 2026-08-14 --- .../appsflyer/AppsFlyerDeepLinkListener.kt | 2 +- .../appsflyer/AppsFlyerAnalyticsClient.kt | 4 +- .../AppsFlyerDeepLinkListenerTest.kt | 90 +++++++++---------- .../appsflyer/AppsFlyerConversionStore.kt | 2 + .../DefaultAppsFlyerConversionStore.kt | 11 +++ .../wallets/DefaultWalletsPromoRepository.kt | 89 ++++++++---------- .../data/wallets/di/WalletsDataModule.kt | 2 - .../repository/WalletsPromoRepository.kt | 9 +- .../usecase/BindRefcodeWithWalletUseCase.kt | 60 +++---------- .../deeplink/DefaultPromoDeeplinkHandler.kt | 38 ++++---- .../DefaultPromoDeeplinkHandlerTest.kt | 18 ++++ 11 files changed, 149 insertions(+), 176 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt index 2b3e47076b..f656143400 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt @@ -73,7 +73,7 @@ class AppsFlyerDeepLinkListener @Inject constructor( private fun storeConversionData(refcode: String, campaign: String?) { coroutineScope.launch { mutex.withLock { - appsFlyerConversionStore.store( + appsFlyerConversionStore.storeIfAbsent( value = AppsFlyerConversionData(refcode = refcode, campaign = campaign), ) } diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt index 27ced0a864..07b0b64fc1 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt @@ -30,10 +30,10 @@ class AppsFlyerClient @AssistedInject constructor( setAppId(context.packageName) setDebugLog(true) - init(apiKey, ConversionListener, context) - subscribeForDeepLink(appsFlyerDeepLinkListener) + init(apiKey, ConversionListener, context) + Timber.i("Starting AppsFlyer SDK") start(context, apiKey, InitializationListener) Timber.i("AppsFlyer SDK started") diff --git a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt index d940d4fdfa..cce586770c 100644 --- a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt @@ -39,57 +39,57 @@ class AppsFlyerDeepLinkListenerTest { fun onDeepLinking(model: OnDeepLinkingModel) = runTest { listener.onDeepLinking(p0 = model.deepLinkResult) - if (model.shouldStore) { - val value = AppsFlyerConversionData(refcode = SUCCESS_REFCODE, campaign = SUCCESS_CAMPAIGN) - coVerify { appsFlyerConversionStore.store(value = value) } - } else { - coVerify(inverse = true) { appsFlyerConversionStore.store(value = any()) } - } + if (model.shouldStore) { + val value = AppsFlyerConversionData(refcode = SUCCESS_REFCODE, campaign = SUCCESS_CAMPAIGN) + coVerify { appsFlyerConversionStore.storeIfAbsent(value = value) } + } else { + coVerify(inverse = true) { appsFlyerConversionStore.storeIfAbsent(value = any()) } } + } private fun provideTestModels(): List { - return listOf( - // DeepLinkResult.Status.NOT_FOUND - OnDeepLinkingModel( - deepLinkResult = DeepLinkResult(null, null), - shouldStore = false, + return listOf( + // DeepLinkResult.Status.NOT_FOUND + OnDeepLinkingModel( + deepLinkResult = DeepLinkResult(null, null), + shouldStore = false, + ), + // DeepLinkResult.Status.ERROR + OnDeepLinkingModel( + deepLinkResult = DeepLinkResult(null, DeepLinkResult.Error.NETWORK), + shouldStore = false, + ), + // DeepLinkResult.Status.FOUND + OnDeepLinkingModel( + deepLinkResult = createFoundDeepLink( + deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE, + refcode = SUCCESS_REFCODE, + campaign = SUCCESS_CAMPAIGN, ), - // DeepLinkResult.Status.ERROR - OnDeepLinkingModel( - deepLinkResult = DeepLinkResult(null, DeepLinkResult.Error.NETWORK), - shouldStore = false, + shouldStore = true, + ), + OnDeepLinkingModel( + deepLinkResult = createFoundDeepLink( + deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE, + refcode = "", + campaign = SUCCESS_CAMPAIGN, ), - // DeepLinkResult.Status.FOUND - OnDeepLinkingModel( - deepLinkResult = createFoundDeepLink( - deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE, - refcode = SUCCESS_REFCODE, - campaign = SUCCESS_CAMPAIGN, - ), - shouldStore = true, + shouldStore = false, + ), + OnDeepLinkingModel( + deepLinkResult = createFoundDeepLink( + deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE, + refcode = null, + campaign = SUCCESS_CAMPAIGN, ), - OnDeepLinkingModel( - deepLinkResult = createFoundDeepLink( - deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE, - refcode = "", - campaign = SUCCESS_CAMPAIGN, - ), - shouldStore = false, - ), - OnDeepLinkingModel( - deepLinkResult = createFoundDeepLink( - deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE, - refcode = null, - campaign = SUCCESS_CAMPAIGN, - ), - shouldStore = false, - ), - OnDeepLinkingModel( - deepLinkResult = createFoundDeepLink(deepLinkValue = "some_other_deep_link_value"), - shouldStore = false, - ), - ) - } + shouldStore = false, + ), + OnDeepLinkingModel( + deepLinkResult = createFoundDeepLink(deepLinkValue = "some_other_deep_link_value"), + shouldStore = false, + ), + ) + } data class OnDeepLinkingModel( val deepLinkResult: DeepLinkResult, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerConversionStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerConversionStore.kt index 99fa70fc9b..300c4c6f20 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerConversionStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerConversionStore.kt @@ -7,4 +7,6 @@ interface AppsFlyerConversionStore { suspend fun get(): AppsFlyerConversionData? suspend fun store(value: AppsFlyerConversionData) + + suspend fun storeIfAbsent(value: AppsFlyerConversionData) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerConversionStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerConversionStore.kt index 50a7e53100..c6a8044516 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerConversionStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerConversionStore.kt @@ -29,6 +29,17 @@ internal class DefaultAppsFlyerConversionStore( appPreferencesStore.storeObject(KEY, dto) } + override suspend fun storeIfAbsent(value: AppsFlyerConversionData) { + Timber.i("Storing conversion data to store if absent: $value") + + val dto = appPreferencesStore.getObjectSyncOrNull(KEY) + + if (dto == null) { + Timber.i("Conversion data is absent, storing $value") + appPreferencesStore.storeObject(KEY, ConversionDataConverter.convert(value)) + } + } + private companion object { val KEY = stringPreferencesKey("APPS_FLYER_CONVERSION_DATA") diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsPromoRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsPromoRepository.kt index 23738373d2..de2bbc191e 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsPromoRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsPromoRepository.kt @@ -1,15 +1,11 @@ package com.tangem.data.wallets import androidx.datastore.preferences.core.stringPreferencesKey -import arrow.core.Option -import arrow.core.toOption import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.tangem.datasource.api.common.response.ApiResponse -import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.BindWalletsByReferralCodeBody -import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull import com.tangem.datasource.local.preferences.utils.storeObject @@ -17,7 +13,6 @@ import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.wallets.models.AppsFlyerConversionData import com.tangem.domain.wallets.repository.WalletsPromoRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.runSuspendCatching import kotlinx.coroutines.withContext import timber.log.Timber @@ -25,69 +20,57 @@ internal class DefaultWalletsPromoRepository( private val appPreferencesStore: AppPreferencesStore, private val tangemTechApi: TangemTechApi, private val userWalletsStore: UserWalletsStore, - private val appsFlyerConversionStore: AppsFlyerConversionStore, private val dispatchers: CoroutineDispatcherProvider, ) : WalletsPromoRepository { - override suspend fun getConversionData(): Option { - return runSuspendCatching { appsFlyerConversionStore.get() }.getOrNull().toOption() + override suspend fun bindRefcodeWithWallets(conversionData: AppsFlyerConversionData) = withContext(dispatchers.io) { + val savedBindingData = getSavedBindingData() + + when { + savedBindingData == null -> { + // first time binding + saveBindingData(refcode = conversionData.refcode, campaign = conversionData.campaign) + bind(refcode = conversionData.refcode, campaign = conversionData.campaign) + } + !savedBindingData.isDone -> { + // try again + bind(refcode = savedBindingData.refcode, campaign = savedBindingData.campaign) + } + } } - override suspend fun saveConversionData(refcode: String, campaign: String?) { - val data = AppsFlyerConversionData(refcode = refcode, campaign = campaign) + override suspend fun retryBindRefcodeWithWallets() { + val savedBindingData = getSavedBindingData() - appsFlyerConversionStore.store(data) + if (savedBindingData != null) { + bind(refcode = savedBindingData.refcode, campaign = savedBindingData.campaign) + } else { + Timber.i("retryBindRefcodeWithWallets: Binding data isn't required") + } } - override suspend fun bindRefcodeWithWallets(refcode: String, campaign: String?) = withContext(dispatchers.io) { + private suspend fun getSavedBindingData(): ReferralWalletsBindingData? { + return appPreferencesStore.getObjectSyncOrNull(key = REFERRAL_WALLETS_BINDING_DATA_KEY) + } + + private suspend fun saveBindingData(refcode: String, campaign: String?) { + val bindingData = ReferralWalletsBindingData(refcode = refcode, campaign = campaign, isDone = false) + + appPreferencesStore.storeObject(key = REFERRAL_WALLETS_BINDING_DATA_KEY, value = bindingData) + } + + private suspend fun bind(refcode: String, campaign: String?) { val walletIds = userWalletsStore.userWalletsSync.map { it.walletId.stringValue } val result = tangemTechApi.bindWalletsByReferralCode( - body = BindWalletsByReferralCodeBody( - walletIds = walletIds, - refcode = refcode, - campaign = campaign, - ), + body = BindWalletsByReferralCodeBody(walletIds = walletIds, refcode = refcode, campaign = campaign), ) - val bindingData = ReferralWalletsBindingData( - refcode = refcode, - campaign = campaign, - isDone = result is ApiResponse.Success, - ) + if (result is ApiResponse.Success) { + val successData = ReferralWalletsBindingData(refcode = refcode, campaign = campaign, isDone = true) - appPreferencesStore.storeObject(key = REFERRAL_WALLETS_BINDING_DATA_KEY, value = bindingData) - - result.getOrThrow() - } - - override suspend fun bindSavedRefcodeWithWallets(): AppsFlyerConversionData { - val bindingData = appPreferencesStore.getObjectSyncOrNull( - key = REFERRAL_WALLETS_BINDING_DATA_KEY, - ) - - if (bindingData == null) { - val exception = IllegalStateException("No saved referral wallets binding data found") - Timber.e(exception) - throw exception + appPreferencesStore.storeObject(key = REFERRAL_WALLETS_BINDING_DATA_KEY, value = successData) } - - val conversionData = AppsFlyerConversionData( - refcode = bindingData.refcode, - campaign = bindingData.campaign, - ) - - if (bindingData.isDone) { - Timber.i("Referral code ${bindingData.refcode} already bound with wallets") - return conversionData - } - - bindRefcodeWithWallets( - refcode = bindingData.refcode, - campaign = bindingData.campaign, - ) - - return conversionData } @JsonClass(generateAdapter = true) diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt b/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt index 5023a6099e..b36b580da9 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt @@ -75,14 +75,12 @@ internal object WalletsDataModule { appPreferencesStore: AppPreferencesStore, tangemTechApi: TangemTechApi, userWalletsStore: UserWalletsStore, - appsFlyerConversionStore: AppsFlyerConversionStore, dispatchers: CoroutineDispatcherProvider, ): WalletsPromoRepository { return DefaultWalletsPromoRepository( appPreferencesStore = appPreferencesStore, tangemTechApi = tangemTechApi, userWalletsStore = userWalletsStore, - appsFlyerConversionStore = appsFlyerConversionStore, dispatchers = dispatchers, ) } diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsPromoRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsPromoRepository.kt index e07fb8e436..d14afae786 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsPromoRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsPromoRepository.kt @@ -1,15 +1,10 @@ package com.tangem.domain.wallets.repository -import arrow.core.Option import com.tangem.domain.wallets.models.AppsFlyerConversionData interface WalletsPromoRepository { - suspend fun getConversionData(): Option + suspend fun bindRefcodeWithWallets(conversionData: AppsFlyerConversionData) - suspend fun saveConversionData(refcode: String, campaign: String?) - - suspend fun bindRefcodeWithWallets(refcode: String, campaign: String?) - - suspend fun bindSavedRefcodeWithWallets(): AppsFlyerConversionData + suspend fun retryBindRefcodeWithWallets() } \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/BindRefcodeWithWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/BindRefcodeWithWalletUseCase.kt index ec454d0aa9..50ea60f794 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/BindRefcodeWithWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/BindRefcodeWithWalletUseCase.kt @@ -1,10 +1,8 @@ package com.tangem.domain.wallets.usecase import arrow.core.Either -import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either -import arrow.core.raise.ensure import com.tangem.domain.wallets.models.AppsFlyerConversionData import com.tangem.domain.wallets.repository.WalletsPromoRepository import kotlinx.coroutines.sync.Mutex @@ -17,59 +15,21 @@ class BindRefcodeWithWalletUseCase @Inject constructor( private val mutex = Mutex() - suspend operator fun invoke(refcode: String, campaign: String?): Either = either { - ensure(refcode.isNotBlank()) { Error.InvalidRefcode } - + suspend operator fun invoke(conversionData: AppsFlyerConversionData): Either = either { mutex.withLock { - checkSavedRefcode() - - bindRefcode(refcode = refcode, campaign = campaign) - - saveConversionData(refcode = refcode, campaign = campaign) + catch( + block = { walletsPromoRepository.bindRefcodeWithWallets(conversionData) }, + catch = ::raise, + ) } } - suspend fun retry(): Either = either { + suspend fun retry(): Either = either { mutex.withLock { - val conversionData = tryBindRefcodeAgain() - - saveConversionData(refcode = conversionData.refcode, campaign = conversionData.campaign) + catch( + block = { walletsPromoRepository.retryBindRefcodeWithWallets() }, + catch = ::raise, + ) } } - - private suspend fun Raise.checkSavedRefcode() { - walletsPromoRepository.getConversionData().onSome { - raise(Error.RefcodeAlreadySaved) - } - } - - private suspend fun Raise.bindRefcode(refcode: String, campaign: String?) { - catch( - block = { walletsPromoRepository.bindRefcodeWithWallets(refcode, campaign) }, - catch = { raise(Error.DataError(it)) }, - ) - } - - private suspend fun Raise.tryBindRefcodeAgain(): AppsFlyerConversionData { - return catch( - block = { walletsPromoRepository.bindSavedRefcodeWithWallets() }, - catch = { raise(Error.DataError(it)) }, - ) - } - - private suspend fun Raise.saveConversionData(refcode: String, campaign: String?) { - catch( - block = { walletsPromoRepository.saveConversionData(refcode, campaign) }, - catch = { raise(Error.DataError(it)) }, - ) - } - - sealed interface Error { - - data object InvalidRefcode : Error - - data object RefcodeAlreadySaved : Error - - data class DataError(val throwable: Throwable) : Error - } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt index 2c0d3c82e8..1592886d35 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt @@ -10,6 +10,7 @@ import com.tangem.core.ui.extensions.mask import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.GlobalLoadingMessage +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.networks.multi.MultiNetworkStatusProducer @@ -18,6 +19,7 @@ import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.wallets.PromoCodeActivationResult import com.tangem.domain.wallets.PromoCodeActivationResult.* +import com.tangem.domain.wallets.models.AppsFlyerConversionData import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import com.tangem.domain.wallets.usecase.ActivateBitcoinPromocodeUseCase import com.tangem.domain.wallets.usecase.BindRefcodeWithWalletUseCase @@ -46,6 +48,7 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val activateBitcoinPromocodeUseCase: ActivateBitcoinPromocodeUseCase, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val appsFlyerConversionStore: AppsFlyerConversionStore, private val bindRefcodeWithWalletUseCase: BindRefcodeWithWalletUseCase, private val analyticsEventsHandler: AnalyticsEventHandler, private val dispatchers: CoroutineDispatcherProvider, @@ -62,6 +65,8 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( } else { findSelectedWallet(promoCode) } + + saveAndBindRefcode() } private fun findSelectedWallet(promoCode: String) { @@ -92,13 +97,9 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( Timber.tag(LOG_TAG).d("All user network statuses ${networkStatuses?.size}") - val cryptoCurrencies = multiWalletCryptoCurrenciesSupplier - .getSyncOrNull( - MultiWalletCryptoCurrenciesProducer.Params( - userWallet - .walletId, - ), - ) + val cryptoCurrencies = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + MultiWalletCryptoCurrenciesProducer.Params(userWallet.walletId), + ) Timber.tag(LOG_TAG).d("All user cryptoCurrencies on main ${cryptoCurrencies?.size}") @@ -106,8 +107,7 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( Timber.tag(LOG_TAG).d("BitcoinCurrency $bitcoinCurrency") val bitcoinStatus = networkStatuses?.firstOrNull { status -> - status.network.id == bitcoinCurrency - ?.network?.id + status.network.id == bitcoinCurrency?.network?.id } Timber.tag(LOG_TAG).d("BitcoinStatus $bitcoinStatus") @@ -129,13 +129,6 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( "Start activation promoCode ${promoCode.mask()} address ${bitcoinAddress.mask()}", ) - if (refcode != null) { - launch { - bindRefcodeWithWalletUseCase(refcode = refcode, campaign = campaign) - .onLeft { Timber.e("Failed to bind refcode with wallets: $it") } - } - } - activatePromoCode(bitcoinAddress = bitcoinAddress, promoCode = promoCode) } else { uiMessageSender.send(GlobalLoadingMessage(false)) @@ -196,6 +189,19 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( ) } + private fun saveAndBindRefcode() { + scope.launch(dispatchers.default) { + if (!refcode.isNullOrBlank()) { + val conversionData = AppsFlyerConversionData(refcode = refcode, campaign = campaign) + + appsFlyerConversionStore.storeIfAbsent(value = conversionData) + + bindRefcodeWithWalletUseCase(conversionData) + .onLeft { Timber.e("Failed to bind refcode with wallets: $it") } + } + } + } + @AssistedFactory interface Factory : PromoDeeplinkHandler.Factory { override fun create( diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt index 126be8d9a7..1c8c5e7577 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt @@ -12,6 +12,7 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -70,6 +71,9 @@ class DefaultPromoDeeplinkHandlerTest { @MockK private lateinit var bindRefcodeWithWalletUseCase: BindRefcodeWithWalletUseCase + @MockK + private lateinit var appsFlyerConversionStore: AppsFlyerConversionStore + private lateinit var messages: MutableList @Before @@ -110,6 +114,7 @@ class DefaultPromoDeeplinkHandlerTest { activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -143,6 +148,7 @@ class DefaultPromoDeeplinkHandlerTest { activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -175,6 +181,7 @@ class DefaultPromoDeeplinkHandlerTest { activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -213,6 +220,7 @@ class DefaultPromoDeeplinkHandlerTest { activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -255,6 +263,7 @@ class DefaultPromoDeeplinkHandlerTest { activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -372,6 +381,7 @@ class DefaultPromoDeeplinkHandlerTest { activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -404,6 +414,7 @@ class DefaultPromoDeeplinkHandlerTest { activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -445,6 +456,7 @@ class DefaultPromoDeeplinkHandlerTest { activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -507,6 +519,7 @@ class DefaultPromoDeeplinkHandlerTest { activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -560,6 +573,7 @@ class DefaultPromoDeeplinkHandlerTest { activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -619,6 +633,7 @@ class DefaultPromoDeeplinkHandlerTest { activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -677,6 +692,7 @@ class DefaultPromoDeeplinkHandlerTest { activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -731,6 +747,7 @@ class DefaultPromoDeeplinkHandlerTest { activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -785,6 +802,7 @@ class DefaultPromoDeeplinkHandlerTest { activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) From 2f48642a142d773c08e8b83b5b5d4513051669c4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 22 Jan 2026 16:11:10 +0300 Subject: [PATCH 7/9] Updated on 2026-08-14 --- app/build.gradle.kts | 2 + .../appsflyer/AppsFlyerDeepLinkListener.kt | 71 +------- .../AppsFlyerReferralParamsHandler.kt | 86 +++++++++ .../appsflyer/TangemAFConversionListener.kt | 32 ++++ .../appsflyer/AppsFlyerAnalyticsClient.kt | 20 +-- .../AppsFlyerDeepLinkListenerTest.kt | 71 +++----- .../AppsFlyerReferralParamsHandlerTest.kt | 164 ++++++++++++++++++ .../TangemAFConversionListenerTest.kt | 63 +++++++ gradle/dependencies.toml | 4 + 9 files changed, 376 insertions(+), 137 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandler.kt create mode 100644 app/src/main/java/com/tangem/tap/common/analytics/appsflyer/TangemAFConversionListener.kt create mode 100644 app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt create mode 100644 app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/TangemAFConversionListenerTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 61936049d7..e6002f64e0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -366,6 +366,8 @@ dependencies { implementation(deps.googlePlay.review) implementation(deps.googlePlay.review.ktx) implementation(deps.googlePlay.services.wallet) + implementation(deps.googlePlay.services) + implementation(deps.googlePlay.advertising) coreLibraryDesugaring(deps.desugar) implementation(deps.timber) implementation(deps.reKotlin) diff --git a/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt index f656143400..ae2547a4a9 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt @@ -1,36 +1,20 @@ package com.tangem.tap.common.analytics.appsflyer -import androidx.annotation.VisibleForTesting -import com.appsflyer.deeplink.DeepLink import com.appsflyer.deeplink.DeepLinkListener import com.appsflyer.deeplink.DeepLinkResult -import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore -import com.tangem.domain.wallets.models.AppsFlyerConversionData -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.contract @Singleton class AppsFlyerDeepLinkListener @Inject constructor( - private val appsFlyerConversionStore: AppsFlyerConversionStore, - dispatchers: CoroutineDispatcherProvider, + private val referralParamsHandler: AppsFlyerReferralParamsHandler, ) : DeepLinkListener { - private val coroutineScope = CoroutineScope(dispatchers.io + SupervisorJob()) - private val mutex = Mutex() - override fun onDeepLinking(p0: DeepLinkResult) { when (p0.status) { DeepLinkResult.Status.FOUND -> { - onDeepLinkFound(p0.deepLink) + referralParamsHandler.handle(deepLink = p0.deepLink) } DeepLinkResult.Status.NOT_FOUND -> { Timber.i("No deep link found") @@ -40,55 +24,4 @@ class AppsFlyerDeepLinkListener @Inject constructor( } } } - - private fun onDeepLinkFound(deepLink: DeepLink) { - if (deepLink.deepLinkValue != REFERRAL_DEEP_LINK_VALUE) { - Timber.i("Ignoring deep link with value: ${deepLink.deepLinkValue ?: "null"}") - return - } - - val refcode = deepLink.getStringValue(REFCODE_PARAM_KEY) - val campaign = deepLink.getStringValue(CAMPAIGN_PARAM_KEY) - - @Suppress("NullableToStringCall") - Timber.i("refcode=$refcode\ncampaign=$campaign") - - if (!isValidParam(refcode)) { - Timber.e("Deep link conversion data is invalid") - return - } - - storeConversionData(refcode = refcode, campaign = campaign) - } - - @OptIn(ExperimentalContracts::class) - private fun isValidParam(value: String?): Boolean { - contract { - returns(true) implies (value != null) - } - - return value != null && value.isNotBlank() && !value.equals("null", ignoreCase = true) - } - - private fun storeConversionData(refcode: String, campaign: String?) { - coroutineScope.launch { - mutex.withLock { - appsFlyerConversionStore.storeIfAbsent( - value = AppsFlyerConversionData(refcode = refcode, campaign = campaign), - ) - } - } - } - - companion object Companion { - - @VisibleForTesting(otherwise = VisibleForTesting.NONE) - const val REFERRAL_DEEP_LINK_VALUE = "referral" - - @VisibleForTesting(otherwise = VisibleForTesting.NONE) - const val REFCODE_PARAM_KEY = "deep_link_sub1" - - @VisibleForTesting(otherwise = VisibleForTesting.NONE) - const val CAMPAIGN_PARAM_KEY = "deep_link_sub2" - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandler.kt new file mode 100644 index 0000000000..21c04dbcac --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandler.kt @@ -0,0 +1,86 @@ +package com.tangem.tap.common.analytics.appsflyer + +import com.appsflyer.deeplink.DeepLink +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +@Singleton +class AppsFlyerReferralParamsHandler @Inject constructor( + private val appsFlyerConversionStore: AppsFlyerConversionStore, + dispatchers: CoroutineDispatcherProvider, +) { + + private val coroutineScope = CoroutineScope(dispatchers.io + SupervisorJob()) + private val mutex = Mutex() + + fun handle(deepLink: DeepLink) { + handle( + deepLinkValue = deepLink.deepLinkValue, + deepLinkSub1 = deepLink.getStringValue(DEEP_LINK_SUB_1), + deepLinkSub2 = deepLink.getStringValue(DEEP_LINK_SUB_2), + ) + } + + fun handle(params: Map) { + handle( + deepLinkValue = params[DEEP_LINK_VALUE] as? String, + deepLinkSub1 = params[DEEP_LINK_SUB_1] as? String, + deepLinkSub2 = params[DEEP_LINK_SUB_2] as? String, + ) + } + + private fun handle(deepLinkValue: String?, deepLinkSub1: String?, deepLinkSub2: String?) { + if (deepLinkValue != REFERRAL_DEEP_LINK_VALUE) { + Timber.i("Ignoring deep link with value: ${deepLinkValue ?: "null"}") + return + } + + @Suppress("NullableToStringCall") + Timber.i("refcode=$deepLinkSub1\ncampaign=$deepLinkSub2") + + if (!isValidParam(deepLinkSub1)) { + Timber.e("Deeplink conversion data is invalid") + return + } + + storeConversionData(refcode = deepLinkSub1, campaign = deepLinkSub2) + } + + @OptIn(ExperimentalContracts::class) + private fun isValidParam(value: String?): Boolean { + contract { + returns(true) implies (value != null) + } + + return value != null && value.isNotBlank() && !value.equals("null", ignoreCase = true) + } + + private fun storeConversionData(refcode: String, campaign: String?) { + coroutineScope.launch { + mutex.withLock { + appsFlyerConversionStore.storeIfAbsent( + value = AppsFlyerConversionData(refcode = refcode, campaign = campaign), + ) + } + } + } + + private companion object { + + const val REFERRAL_DEEP_LINK_VALUE = "referral" + const val DEEP_LINK_VALUE = "deep_link_value" + const val DEEP_LINK_SUB_1 = "deep_link_sub1" + const val DEEP_LINK_SUB_2 = "deep_link_sub2" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/TangemAFConversionListener.kt b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/TangemAFConversionListener.kt new file mode 100644 index 0000000000..a051eefeb9 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/TangemAFConversionListener.kt @@ -0,0 +1,32 @@ +package com.tangem.tap.common.analytics.appsflyer + +import com.appsflyer.AppsFlyerConversionListener +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TangemAFConversionListener @Inject constructor( + private val referralParamsHandler: AppsFlyerReferralParamsHandler, +) : AppsFlyerConversionListener { + + override fun onConversionDataSuccess(p0: Map?) { + Timber.i("AppsFlyer conversion data success: ${p0.orEmpty()}") + + if (p0 == null) return + + referralParamsHandler.handle(params = p0) + } + + override fun onConversionDataFail(p0: String?) { + Timber.e("AppsFlyer conversion data failure: ${p0.orEmpty()}") + } + + override fun onAppOpenAttribution(p0: Map?) { + Timber.i("AppsFlyer app open attribution: ${p0.orEmpty()}") + } + + override fun onAttributionFailure(p0: String?) { + Timber.e("AppsFlyer attribution failure: ${p0.orEmpty()}") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt index 07b0b64fc1..77227aa7c2 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt @@ -1,12 +1,12 @@ package com.tangem.tap.common.analytics.handlers.appsflyer import android.content.Context -import com.appsflyer.AppsFlyerConversionListener import com.appsflyer.AppsFlyerLib import com.appsflyer.attribution.AppsFlyerRequestListener import com.tangem.core.analytics.api.EventLogger import com.tangem.core.analytics.api.UserIdHolder import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeepLinkListener +import com.tangem.tap.common.analytics.appsflyer.TangemAFConversionListener import com.tangem.tap.common.analytics.handlers.firebase.UnderscoreAnalyticsEventConverter import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -20,6 +20,7 @@ class AppsFlyerClient @AssistedInject constructor( @Assisted apiKey: String, @ApplicationContext private val context: Context, appsFlyerDeepLinkListener: AppsFlyerDeepLinkListener, + tangemAFConversionListener: TangemAFConversionListener, ) : AppsFlyerAnalyticsClient { private val appsFlyerLib: AppsFlyerLib = AppsFlyerLib.getInstance() @@ -32,7 +33,7 @@ class AppsFlyerClient @AssistedInject constructor( subscribeForDeepLink(appsFlyerDeepLinkListener) - init(apiKey, ConversionListener, context) + init(apiKey, tangemAFConversionListener, context) Timber.i("Starting AppsFlyer SDK") start(context, apiKey, InitializationListener) @@ -58,21 +59,6 @@ class AppsFlyerClient @AssistedInject constructor( ) } - private object ConversionListener : AppsFlyerConversionListener { - override fun onConversionDataSuccess(p0: Map?) { - Timber.i("AppsFlyer conversion data success: ${p0.orEmpty()}") - } - override fun onConversionDataFail(p0: String?) { - Timber.e("AppsFlyer conversion data failure: ${p0.orEmpty()}") - } - override fun onAppOpenAttribution(p0: Map?) { - Timber.i("AppsFlyer app open attribution: ${p0.orEmpty()}") - } - override fun onAttributionFailure(p0: String?) { - Timber.e("AppsFlyer attribution failure: ${p0.orEmpty()}") - } - } - private object InitializationListener : AppsFlyerRequestListener { override fun onSuccess() { Timber.d("AppsFlyer initialized successfully") diff --git a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt index cce586770c..20f770e186 100644 --- a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt @@ -2,16 +2,8 @@ package com.tangem.tap.common.analytics.appsflyer import com.appsflyer.deeplink.DeepLink import com.appsflyer.deeplink.DeepLinkResult -import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore -import com.tangem.domain.wallets.models.AppsFlyerConversionData -import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeepLinkListener.Companion.CAMPAIGN_PARAM_KEY -import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeepLinkListener.Companion.REFCODE_PARAM_KEY import com.tangem.test.core.ProvideTestModels -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.clearMocks -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk +import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.TestInstance @@ -23,27 +15,29 @@ import org.junit.jupiter.params.ParameterizedTest @TestInstance(TestInstance.Lifecycle.PER_CLASS) class AppsFlyerDeepLinkListenerTest { - private val appsFlyerConversionStore: AppsFlyerConversionStore = mockk(relaxUnitFun = true) + private val referralParamsHandler: AppsFlyerReferralParamsHandler = mockk() private val listener = AppsFlyerDeepLinkListener( - appsFlyerConversionStore = appsFlyerConversionStore, - dispatchers = TestingCoroutineDispatcherProvider(), + referralParamsHandler = referralParamsHandler, ) @AfterEach fun tearDown() { - clearMocks(appsFlyerConversionStore) + clearMocks(referralParamsHandler) } @ParameterizedTest @ProvideTestModels fun onDeepLinking(model: OnDeepLinkingModel) = runTest { + if (model.shouldHandle) { + every { referralParamsHandler.handle(deepLink = model.deepLinkResult.deepLink) } just Runs + } + listener.onDeepLinking(p0 = model.deepLinkResult) - if (model.shouldStore) { - val value = AppsFlyerConversionData(refcode = SUCCESS_REFCODE, campaign = SUCCESS_CAMPAIGN) - coVerify { appsFlyerConversionStore.storeIfAbsent(value = value) } + if (model.shouldHandle) { + coVerify { referralParamsHandler.handle(deepLink = model.deepLinkResult.deepLink) } } else { - coVerify(inverse = true) { appsFlyerConversionStore.storeIfAbsent(value = any()) } + coVerify(inverse = true) { referralParamsHandler.handle(deepLink = any()) } } } @@ -52,48 +46,28 @@ class AppsFlyerDeepLinkListenerTest { // DeepLinkResult.Status.NOT_FOUND OnDeepLinkingModel( deepLinkResult = DeepLinkResult(null, null), - shouldStore = false, + shouldHandle = false, ), // DeepLinkResult.Status.ERROR OnDeepLinkingModel( deepLinkResult = DeepLinkResult(null, DeepLinkResult.Error.NETWORK), - shouldStore = false, + shouldHandle = false, ), // DeepLinkResult.Status.FOUND OnDeepLinkingModel( deepLinkResult = createFoundDeepLink( - deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE, - refcode = SUCCESS_REFCODE, - campaign = SUCCESS_CAMPAIGN, + deepLinkValue = "deep_link_value", + refcode = "refcode", + campaign = "campaign", ), - shouldStore = true, - ), - OnDeepLinkingModel( - deepLinkResult = createFoundDeepLink( - deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE, - refcode = "", - campaign = SUCCESS_CAMPAIGN, - ), - shouldStore = false, - ), - OnDeepLinkingModel( - deepLinkResult = createFoundDeepLink( - deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE, - refcode = null, - campaign = SUCCESS_CAMPAIGN, - ), - shouldStore = false, - ), - OnDeepLinkingModel( - deepLinkResult = createFoundDeepLink(deepLinkValue = "some_other_deep_link_value"), - shouldStore = false, + shouldHandle = true, ), ) } data class OnDeepLinkingModel( val deepLinkResult: DeepLinkResult, - val shouldStore: Boolean, + val shouldHandle: Boolean, ) private fun createFoundDeepLink( @@ -103,8 +77,8 @@ class AppsFlyerDeepLinkListenerTest { ): DeepLinkResult { val deeplink = mockk { every { this@mockk.deepLinkValue } returns deepLinkValue - every { this@mockk.getStringValue(REFCODE_PARAM_KEY) } returns refcode - every { this@mockk.getStringValue(CAMPAIGN_PARAM_KEY) } returns campaign + every { this@mockk.getStringValue("deep_link_sub1") } returns refcode + every { this@mockk.getStringValue("deep_link_sub2") } returns campaign } return mockk { @@ -112,9 +86,4 @@ class AppsFlyerDeepLinkListenerTest { every { this@mockk.deepLink } returns deeplink } } - - private companion object { - const val SUCCESS_REFCODE = "valid_refcode" - const val SUCCESS_CAMPAIGN = "valid_campaign" - } } \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt new file mode 100644 index 0000000000..41e17cbddc --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt @@ -0,0 +1,164 @@ +package com.tangem.tap.common.analytics.appsflyer + +import com.appsflyer.deeplink.DeepLink +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.test.core.ProvideTestModels +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AppsFlyerReferralParamsHandlerTest { + + private val appsFlyerConversionStore: AppsFlyerConversionStore = mockk(relaxUnitFun = true) + private val handler = AppsFlyerReferralParamsHandler( + appsFlyerConversionStore = appsFlyerConversionStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @AfterEach + fun tearDown() { + clearMocks(appsFlyerConversionStore) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class HandleDeepLink { + + @ParameterizedTest + @ProvideTestModels + fun handle(model: HandleDeepLinkModel) = runTest { + handler.handle(deepLink = model.deepLink) + + if (model.shouldStore) { + val value = AppsFlyerConversionData(refcode = SUCCESS_REFCODE, campaign = SUCCESS_CAMPAIGN) + coVerify { appsFlyerConversionStore.storeIfAbsent(value = value) } + } else { + coVerify(inverse = true) { appsFlyerConversionStore.storeIfAbsent(value = any()) } + } + } + + private fun provideTestModels(): List { + return listOf( + HandleDeepLinkModel( + deepLink = createDeepLink( + deepLinkValue = "referral", + refcode = SUCCESS_REFCODE, + campaign = SUCCESS_CAMPAIGN, + ), + shouldStore = true, + ), + HandleDeepLinkModel( + deepLink = createDeepLink(deepLinkValue = "some_other_deep_link_value"), + shouldStore = false, + ), + HandleDeepLinkModel( + deepLink = createDeepLink(deepLinkValue = ""), + shouldStore = false, + ), + HandleDeepLinkModel( + deepLink = createDeepLink( + deepLinkValue = "referral", + refcode = "", + campaign = SUCCESS_CAMPAIGN, + ), + shouldStore = false, + ), + HandleDeepLinkModel( + deepLink = createDeepLink( + deepLinkValue = "referral", + refcode = null, + campaign = SUCCESS_CAMPAIGN, + ), + shouldStore = false, + ), + ) + } + + private fun createDeepLink( + deepLinkValue: String, + refcode: String? = null, + campaign: String? = null, + ): DeepLink { + return mockk { + every { this@mockk.deepLinkValue } returns deepLinkValue + every { this@mockk.getStringValue("deep_link_sub1") } returns refcode + every { this@mockk.getStringValue("deep_link_sub2") } returns campaign + } + } + } + + data class HandleDeepLinkModel(val deepLink: DeepLink, val shouldStore: Boolean) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class HandleParams { + + @ParameterizedTest + @ProvideTestModels + fun handle(model: HandleParamsModel) = runTest { + handler.handle(params = model.params) + + if (model.shouldStore) { + val value = AppsFlyerConversionData(refcode = SUCCESS_REFCODE, campaign = SUCCESS_CAMPAIGN) + coVerify { appsFlyerConversionStore.storeIfAbsent(value = value) } + } else { + coVerify(inverse = true) { appsFlyerConversionStore.storeIfAbsent(value = any()) } + } + } + + private fun provideTestModels(): List { + return listOf( + HandleParamsModel( + params = mapOf( + "deep_link_value" to "referral", + "deep_link_sub1" to SUCCESS_REFCODE, + "deep_link_sub2" to SUCCESS_CAMPAIGN, + ), + shouldStore = true, + ), + HandleParamsModel( + params = mapOf("deep_link_value" to "some_other_deep_link_value"), + shouldStore = false, + ), + HandleParamsModel( + params = mapOf("deep_link_value" to ""), + shouldStore = false, + ), + HandleParamsModel( + params = mapOf( + "deep_link_value" to "referral", + "deep_link_sub1" to "", + "deep_link_sub2" to SUCCESS_CAMPAIGN, + ), + shouldStore = false, + ), + HandleParamsModel( + params = mapOf( + "deep_link_value" to "referral", + "deep_link_sub2" to SUCCESS_CAMPAIGN, + ), + shouldStore = false, + ), + ) + } + } + + data class HandleParamsModel(val params: Map, val shouldStore: Boolean) + + private companion object Companion { + const val SUCCESS_REFCODE = "valid_refcode" + const val SUCCESS_CAMPAIGN = "valid_campaign" + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/TangemAFConversionListenerTest.kt b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/TangemAFConversionListenerTest.kt new file mode 100644 index 0000000000..f66b08aa16 --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/TangemAFConversionListenerTest.kt @@ -0,0 +1,63 @@ +package com.tangem.tap.common.analytics.appsflyer + +import com.tangem.test.core.ProvideTestModels +import io.mockk.clearMocks +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TangemAFConversionListenerTest { + + private val referralParamsHandler: AppsFlyerReferralParamsHandler = mockk(relaxUnitFun = true) + private val listener = TangemAFConversionListener( + referralParamsHandler = referralParamsHandler, + ) + + @AfterEach + fun tearDown() { + clearMocks(referralParamsHandler) + } + + @ParameterizedTest + @ProvideTestModels + fun onConversionDataSuccess(model: OnConversionDataSuccessModel) = runTest { + listener.onConversionDataSuccess(p0 = model.params) + + if (model.shouldHandle) { + coVerify { referralParamsHandler.handle(params = model.params!!) } + } else { + coVerify(inverse = true) { referralParamsHandler.handle(params = any()) } + } + } + + private fun provideTestModels(): List { + return listOf( + OnConversionDataSuccessModel( + params = null, + shouldHandle = false, + ), + OnConversionDataSuccessModel( + params = emptyMap(), + shouldHandle = true, + ), + OnConversionDataSuccessModel( + params = mapOf( + "deep_link_value" to "referral", + ), + shouldHandle = true, + ), + ) + } + + data class OnConversionDataSuccessModel( + val params: Map?, + val shouldHandle: Boolean, + ) +} \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index f284f5dc0c..45d0d78603 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -5,6 +5,8 @@ androidGradlePlugin = "8.10.1" firebaseCrashlytics = "3.0.1" googleServices = "4.4.1" +playServices = "18.10.0" +playServicesAds = "18.2.0" kotlin = "2.1.10" ksp = "2.1.10-1.0.30" firebasePerf = "1.4.2" @@ -254,6 +256,8 @@ desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desuga googlePlay-review = { module = "com.google.android.play:review", version.ref = "googlePlayReview" } googlePlay-review-ktx = { module = "com.google.android.play:review-ktx", version.ref = "googlePlayReviewKtx" } googlePlay-services-wallet = { module = "com.google.android.gms:play-services-wallet", version.ref = "googlePlayServicesWallet" } +googlePlay-services = { module = "com.google.android.gms:play-services-base", version.ref = "playServices" } +googlePlay-advertising = { module = "com.google.android.gms:play-services-ads-identifier", version.ref = "playServicesAds" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-core = { module = "com.google.dagger:hilt-core", version.ref = "hilt" } hilt-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } From 7310b748769b5ce5d64aa501f08df2a59c04f227 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 26 Jan 2026 17:29:32 +0300 Subject: [PATCH 8/9] Updated on 2026-08-14 --- .../src/main/assets/configs/feature_toggles_config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index c2e3aa55ea..abff08deb7 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -41,7 +41,7 @@ }, { "name": "HOT_WALLET_VISIBLE", - "version": "undefined" + "version": "5.32.1" }, { "name": "TANGEM_PAY_ENABLED", From 7530ff96cb6c0c664275b152e125ee38682b22af Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 26 Jan 2026 14:29:59 +0000 Subject: [PATCH 9/9] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index d8e493fcf3..c2e88984ac 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.32-1388" +tangemBlockchainSdk = "develop-1387" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.32-574" +tangemCardSdk = "develop-573" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-541" +tangemHotSdk = "develop-539" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^