Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-03 17:45:52 +03:00
parent a2c8e7e95a
commit 505413483e
26 changed files with 467 additions and 321 deletions

View file

@ -28,6 +28,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import arrow.core.getOrElse
import com.appsflyer.AppsFlyerLib
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.WEBLINK_KEY
import com.tangem.common.routing.deeplink.PayloadToDeeplinkConverter
@ -367,6 +368,9 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
super.onNewIntent(intent)
TangemLogger.i("onNewIntent: data=${intent.data}, extras=${intent.extras?.keySet()}")
// Warm start: let the AppsFlyer SDK resolve a OneLink delivered while the app is already running.
AppsFlyerLib.getInstance().performOnDeepLinking(intent, this)
val isFromPush = intent.extras?.containsKey(OPENED_FROM_GCM_PUSH) == true
if (isFromPush) {
analyticsEventsHandler.send(Push.PushNotificationOpened())

View file

@ -13,17 +13,9 @@ class AppsFlyerDeepLinkListener @Inject constructor(
override fun onDeepLinking(p0: DeepLinkResult) {
when (p0.status) {
DeepLinkResult.Status.FOUND -> {
referralParamsHandler.handleDeeplink(deepLink = p0.deepLink)
}
DeepLinkResult.Status.NOT_FOUND -> {
referralParamsHandler.handleNoDeeplink()
TangemLogger.i("No deep link found")
}
DeepLinkResult.Status.ERROR -> {
referralParamsHandler.handleNoDeeplink()
TangemLogger.e("Deep link error: ${p0.error}")
}
DeepLinkResult.Status.FOUND -> referralParamsHandler.handleDeeplink(deepLink = p0.deepLink)
DeepLinkResult.Status.NOT_FOUND -> TangemLogger.i("No deep link found")
DeepLinkResult.Status.ERROR -> TangemLogger.e("Deep link error: ${p0.error}")
}
}
}

View file

@ -0,0 +1,97 @@
package com.tangem.tap.common.analytics.appsflyer
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.appsflyer.AppsFlyerDeeplink
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reactively routes AppsFlyer deep links persisted by [AppsFlyerReferralParamsHandler].
*
* To add a deep link: add it to [AppsFlyerDeeplink] and a branch in [onDeeplinkPending] (the `when` is exhaustive).
*/
@Singleton
class AppsFlyerDeeplinkRouter @Inject constructor(
private val appsFlyerStore: AppsFlyerStore,
private val userWalletsListRepository: UserWalletsListRepository,
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
private val featureTogglesManager: FeatureTogglesManager,
private val appRouter: AppRouter,
) {
fun observe(scope: CoroutineScope, currentRoute: Flow<AppRoute?>) {
combine(
appsFlyerStore.observeNavigationDeeplink(),
currentRoute.distinctUntilChanged(),
) { deepLinkValue, route ->
if (deepLinkValue != null && route != null) deepLinkValue to route else null
}
.filterNotNull()
.onEach { (deepLinkValue, route) -> onDeeplinkPending(deepLinkValue, route) }
.launchIn(scope)
}
private suspend fun onDeeplinkPending(deepLinkValue: String, currentRoute: AppRoute) {
when (AppsFlyerDeeplink.from(deepLinkValue)) {
AppsFlyerDeeplink.TangemPayMobileOnboarding -> routeTangemPayOnboarding(currentRoute)
AppsFlyerDeeplink.Referral -> routeReferral(currentRoute)
null -> TangemLogger.i("Ignoring unknown AppsFlyer deep link value: $deepLinkValue")
}
}
private suspend fun routeTangemPayOnboarding(currentRoute: AppRoute) {
val isEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING,
)
if (!isEnabled) return
// Not on an idle entry screen yet: keep the deep link pending, re-evaluate on next route change.
if (!isIdleEntryPoint(currentRoute)) return
if (userWalletsListRepository.userWalletsSync().isNotEmpty()) {
TangemLogger.i("[TangemPay][HWO] Routing AppsFlyer deep link to Tangem Pay onboarding")
// Authorized: push onto the wallet screen so Back returns to it.
appRouter.push(AppRoute.TangemPayOnboarding(AppRoute.TangemPayOnboarding.Mode.MobileOnboardingDeeplink))
} else {
TangemLogger.i("[TangemPay][HWO] Routing AppsFlyer deep link to hot wallet onboarding")
// Not authorized: open onboarding as the root, skipping Home/stories (original cold-start flow).
appRouter.replaceAll(AppRoute.TangemPayHotWalletOnboarding)
}
// One-shot: consume the deep link once routed so the user isn't forced back here on relaunch.
clearAppsFlyerDeeplinkUseCase()
}
private suspend fun routeReferral(currentRoute: AppRoute) {
val isEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED,
)
if (!isEnabled) return
// Referral targets fresh installs: from an idle entry screen, go straight to hot wallet creation
// (skips stories). The deep link is NOT cleared here — it stays as referral attribution (read by
// IsReferralInstallUseCase, cleared on wallet creation); replaceAll keeps it off the back stack.
if (!isIdleEntryPoint(currentRoute)) return
if (userWalletsListRepository.userWalletsSync().isNotEmpty()) return
TangemLogger.i("[Referral] Routing AppsFlyer referral deep link to hot wallet creation")
appRouter.replaceAll(AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet))
}
}
// Route only from idle entry screens so an in-progress flow (scan, KYC, onboarding…) isn't interrupted.
internal fun isIdleEntryPoint(currentRoute: AppRoute?): Boolean =
currentRoute is AppRoute.Home || currentRoute is AppRoute.Stories || currentRoute is AppRoute.Wallet

View file

@ -1,12 +1,11 @@
package com.tangem.tap.common.analytics.appsflyer
import com.appsflyer.deeplink.DeepLink
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.appsflyer.AppsFlyerDeeplink
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@ -19,8 +18,6 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
private val coroutineScope: AppCoroutineScope,
) {
private val deepLinkDeferred = CompletableDeferred<String?>()
fun handle(params: Map<String?, Any?>) {
handle(
deepLinkValue = params[DEEP_LINK_VALUE] as? String,
@ -35,40 +32,15 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
deepLinkSub1 = deepLink.getStringValue(DEEP_LINK_SUB_1),
deepLinkSub2 = deepLink.getStringValue(DEEP_LINK_SUB_2),
)
deepLinkDeferred.complete(deepLink.deepLinkValue)
}
fun handleNoDeeplink() {
deepLinkDeferred.complete(null)
}
suspend fun waitForDeeplink(deeplinkSource: AppsFlyerDeeplinkSource): String? {
appsFlyerStore.getDeeplink(deeplinkSource)?.let { return it }
val expectedValue = when (deeplinkSource) {
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE
AppsFlyerDeeplinkSource.Referral -> REFERRAL_DEEP_LINK_VALUE
}
val resolvedValue = deepLinkDeferred.await().takeIf { it == expectedValue }
// The deep link may have been persisted to the store while we were awaiting (e.g. from
// conversion-data handling, which stores the deep link but doesn't complete the deferred).
return resolvedValue ?: appsFlyerStore.getDeeplink(deeplinkSource)
}
private fun handle(deepLinkValue: String?, deepLinkSub1: String?, deepLinkSub2: String?) {
TangemLogger.i("AppsFlyer deeplink received: value=$deepLinkValue")
when (deepLinkValue) {
REFERRAL_DEEP_LINK_VALUE -> handleReferral(deepLinkSub1, deepLinkSub2)
TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE -> handleTangemPayHotWalletOnboarding(deepLinkValue)
else -> TangemLogger.i("Ignoring deep link with value: ${deepLinkValue ?: "null"}")
}
}
private fun handleTangemPayHotWalletOnboarding(deepLinkValue: String) {
coroutineScope.launch {
appsFlyerStore.storeDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding, deepLinkValue)
TangemLogger.i("[TangemPay][HWO] Deep link stored")
when (AppsFlyerDeeplink.from(deepLinkValue)) {
AppsFlyerDeeplink.Referral -> handleReferral(deepLinkSub1, deepLinkSub2)
AppsFlyerDeeplink.TangemPayMobileOnboarding ->
storeNavigationDeeplink(AppsFlyerDeeplink.TangemPayMobileOnboarding.deepLinkValue)
null -> TangemLogger.i("Ignoring deep link with value: ${deepLinkValue ?: "null"}")
}
}
@ -76,9 +48,7 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
@Suppress("NullableToStringCall")
TangemLogger.i("refcode=$deepLinkSub1\ncampaign=$deepLinkSub2")
coroutineScope.launch {
appsFlyerStore.storeDeeplink(AppsFlyerDeeplinkSource.Referral, REFERRAL_DEEP_LINK_VALUE)
}
storeNavigationDeeplink(AppsFlyerDeeplink.Referral.deepLinkValue)
if (!isValidParam(deepLinkSub1)) {
TangemLogger.e("Deeplink conversion data is invalid")
@ -97,6 +67,13 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
return value != null && value.isNotBlank() && !value.equals("null", ignoreCase = true)
}
private fun storeNavigationDeeplink(deepLinkValue: String) {
coroutineScope.launch {
appsFlyerStore.storeNavigationDeeplink(deepLinkValue)
TangemLogger.i("AppsFlyer navigation deep link stored: $deepLinkValue")
}
}
private fun storeConversionData(refcode: String, campaign: String?) {
coroutineScope.launch {
appsFlyerStore.storeIfAbsent(
@ -107,9 +84,6 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
private companion object {
const val REFERRAL_DEEP_LINK_VALUE = "referral"
const val TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE = "tpay_mobileonboard"
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"

View file

@ -36,9 +36,9 @@ class AppsFlyerClient @AssistedInject constructor(
setAppId(context.packageName)
setDebugLog(true)
subscribeForDeepLink(appsFlyerDeepLinkListener)
init(apiKey, tangemAFConversionListener, context)
subscribeForDeepLink(appsFlyerDeepLinkListener)
setOneLinkCustomDomain(BRANDED_ONELINK_DOMAIN)
TangemLogger.i("Starting AppsFlyer SDK")
start(context, apiKey, InitializationListener)
@ -100,4 +100,9 @@ class AppsFlyerClient @AssistedInject constructor(
interface Factory {
fun create(apiKey: String): AppsFlyerClient
}
private companion object {
// Branded AppsFlyer OneLink domain (matches the join.tangem.com App Link filter in AndroidManifest).
const val BRANDED_ONELINK_DOMAIN = "join.tangem.com"
}
}

View file

@ -14,7 +14,6 @@ import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
import com.tangem.domain.common.wallets.UserWalletTransformAction
@ -622,12 +621,12 @@ internal class DefaultUserWalletsListRepository(
private suspend fun onFirstWalletCreated() {
// reset the referral attribution (set from AF deeplink) after creating a new wallet
clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.Referral)
clearAppsFlyerDeeplinkUseCase()
}
private suspend fun onAllWalletsDeleted() {
// reset the referral attribution (set from AF deeplink) after removing the last wallet
clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.Referral)
clearAppsFlyerDeeplinkUseCase()
appPreferencesStore.editData { it.remove(PreferencesKeys.USEDESK_CLIENT_ID_KEY) }
}
}

View file

@ -19,8 +19,6 @@ import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
@ -31,7 +29,6 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.scan.ScanResponse
@ -52,7 +49,7 @@ import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.android.create
import com.tangem.sdk.api.BackupServiceHolder
import com.tangem.tap.common.SnackbarHandler
import com.tangem.tap.common.analytics.appsflyer.AppsFlyerReferralParamsHandler
import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeeplinkRouter
import com.tangem.tap.features.hot.TangemHotSDKProxy
import com.tangem.tap.features.scanfails.ScanFailsComponent
import com.tangem.tap.features.scanfails.ScanFailsRequesterProxy
@ -68,11 +65,8 @@ import com.tangem.wallet.R
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
@Suppress("LongParameterList", "LargeClass")
internal class DefaultRoutingComponent @AssistedInject constructor(
@ -91,7 +85,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val userWalletsListRepository: UserWalletsListRepository,
private val cardRepository: CardRepository,
private val onboardingRepository: OnboardingRepository,
private val appsFlyerReferralParamsHandler: AppsFlyerReferralParamsHandler,
private val appsFlyerDeeplinkRouter: AppsFlyerDeeplinkRouter,
private val trackingContextProxy: TrackingContextProxy,
private val scanFailsComponentFactory: ScanFailsComponent.Factory,
private val scanFailsRequesterProxy: ScanFailsRequesterProxy,
@ -102,7 +96,6 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val neverToInitiallyAskPermissionUseCase: NeverToInitiallyAskPermissionUseCase,
private val shouldInitiallyAskPermissionUseCase: ShouldInitiallyAskPermissionUseCase,
private val neverRequestPermissionUseCase: NeverRequestPermissionUseCase,
private val featureTogglesManager: FeatureTogglesManager,
) : RoutingComponent,
AppComponentContext by context,
SnackbarHandler {
@ -146,6 +139,8 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
},
)
private val currentRoute = MutableStateFlow<AppRoute?>(null)
init {
appRouterConfig.routerScope = componentScope
appRouterConfig.componentRouter = router
@ -160,6 +155,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
val stackItems = stack.items.map { it.configuration }
currentRoute.value = stack.active.configuration
wcRoutingComponent.onAppRouteChange(stack.active.configuration)
deeplinkFactory.checkRoutingReadiness(stack.active.configuration)
@ -170,6 +166,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
configureProxies()
initializeInitialNavigation()
appsFlyerDeeplinkRouter.observe(scope = componentScope, currentRoute = currentRoute)
}
private fun initializeInitialNavigation() {
@ -206,8 +203,9 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
}
private suspend fun navigateForEmptyWallets(): AppRoute {
val afterEmptyRoute = resolveAppsFlyerOnboardingRoute()
?: AppRoute.Home(launchMode = launchMode)
// AppsFlyer deep links (referral, tpay_mobileonboard) are routed reactively by AppsFlyerDeeplinkRouter
// once we settle on an idle screen — not resolved here.
val afterEmptyRoute = AppRoute.Home(launchMode = launchMode)
val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull()
?: return afterEmptyRoute
@ -227,44 +225,6 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
}
}
private suspend fun resolveAppsFlyerOnboardingRoute(): AppRoute? = coroutineScope {
val tangemPayRoute = async { resolveTangemPayHotWalletOnboardingRoute() }
val referralRoute = async { resolveReferralRoute() }
tangemPayRoute.await() ?: referralRoute.await()
}
private suspend fun resolveTangemPayHotWalletOnboardingRoute(): AppRoute? {
val isEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING,
)
TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isEnabled")
if (!isEnabled) return null
val deepLink = awaitAppsFlyerDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
TangemLogger.i("[TangemPay][HWO] Deep link present=${deepLink != null}")
return if (deepLink != null) AppRoute.TangemPayHotWalletOnboarding else null
}
private suspend fun resolveReferralRoute(): AppRoute? {
val isEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED,
)
if (!isEnabled) return null
val referralDeepLink = awaitAppsFlyerDeeplink(AppsFlyerDeeplinkSource.Referral)
return if (referralDeepLink != null) {
AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet)
} else {
null
}
}
private suspend fun awaitAppsFlyerDeeplink(source: AppsFlyerDeeplinkSource): String? {
return withTimeoutOrNull(2.seconds) {
appsFlyerReferralParamsHandler.waitForDeeplink(source)
}
}
@Composable
override fun Content(modifier: Modifier) {
RootContent(

View file

@ -690,6 +690,7 @@ internal class ChildFactory @Inject constructor(
)
is AppRoute.TangemPayOnboarding.Mode.FromBannerInSettings -> FromBannerInSettings
is AppRoute.TangemPayOnboarding.Mode.FromBannerOnMain -> FromBannerOnMain
is AppRoute.TangemPayOnboarding.Mode.MobileOnboardingDeeplink -> MobileOnboardingDeeplink
},
componentFactory = tangemPayOnboardingComponentFactory,
)

View file

@ -30,18 +30,14 @@ class AppsFlyerDeepLinkListenerTest {
fun onDeepLinking(model: OnDeepLinkingModel) = runTest {
if (model.shouldHandle) {
every { referralParamsHandler.handleDeeplink(deepLink = model.deepLinkResult.deepLink) } just Runs
} else {
every { referralParamsHandler.handleNoDeeplink() } just Runs
}
listener.onDeepLinking(p0 = model.deepLinkResult)
if (model.shouldHandle) {
coVerify { referralParamsHandler.handleDeeplink(deepLink = model.deepLinkResult.deepLink) }
verify(inverse = true) { referralParamsHandler.handleNoDeeplink() }
verify { referralParamsHandler.handleDeeplink(deepLink = model.deepLinkResult.deepLink) }
} else {
coVerify(inverse = true) { referralParamsHandler.handleDeeplink(deepLink = any()) }
verify { referralParamsHandler.handleNoDeeplink() }
verify(inverse = true) { referralParamsHandler.handleDeeplink(deepLink = any()) }
}
}

View file

@ -0,0 +1,180 @@
package com.tangem.tap.common.analytics.appsflyer
import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.test.core.ProvideTestModels
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class AppsFlyerDeeplinkRouterTest {
private val appsFlyerStore: AppsFlyerStore = mockk()
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase = mockk(relaxed = true)
private val featureTogglesManager: FeatureTogglesManager = mockk()
private val appRouter: AppRouter = mockk(relaxed = true)
private val router = AppsFlyerDeeplinkRouter(
appsFlyerStore = appsFlyerStore,
userWalletsListRepository = userWalletsListRepository,
clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase,
featureTogglesManager = featureTogglesManager,
appRouter = appRouter,
)
@BeforeEach
fun setup() {
clearMocks(
appsFlyerStore,
userWalletsListRepository,
clearAppsFlyerDeeplinkUseCase,
featureTogglesManager,
appRouter,
)
// Happy baseline: feature on, deep link pending, authorized.
every { featureTogglesManager.isFeatureEnabled(any()) } returns true
every { appsFlyerStore.observeNavigationDeeplink() } returns flowOf("tpay_mobileonboard")
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(mockk<UserWallet>())
}
// region idle entry point
@ParameterizedTest
@ProvideTestModels
fun isIdle(model: IdleModel) {
assertThat(isIdleEntryPoint(model.currentRoute)).isEqualTo(model.expected)
}
private fun provideTestModels(): List<IdleModel> = listOf(
// Idle entry screens — a deep link may route from here.
IdleModel(AppRoute.Home(), expected = true),
IdleModel(AppRoute.Stories(storyId = "id", screenSource = "src"), expected = true),
IdleModel(AppRoute.Wallet, expected = true),
// In-progress / already-on-onboarding / startup routes — must not be interrupted.
IdleModel(AppRoute.Initial, expected = false),
IdleModel(AppRoute.Disclaimer(isTosAccepted = true), expected = false),
IdleModel(AppRoute.TangemPayHotWalletOnboarding, expected = false),
IdleModel(AppRoute.TangemPayOnboarding(AppRoute.TangemPayOnboarding.Mode.MobileOnboardingDeeplink), expected = false),
IdleModel(currentRoute = null, expected = false),
)
data class IdleModel(val currentRoute: AppRoute?, val expected: Boolean)
// endregion
// region reactive observe
@Test
fun `GIVEN deeplink and authorized wallet on Wallet WHEN observe THEN push onboarding and clear`() = runTest(UnconfinedTestDispatcher()) {
// Arrange
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(mockk<UserWallet>())
val currentRoute = MutableStateFlow<AppRoute?>(AppRoute.Wallet)
// Act
router.observe(backgroundScope, currentRoute)
advanceUntilIdle()
// Assert
verify {
appRouter.push(
route = AppRoute.TangemPayOnboarding(AppRoute.TangemPayOnboarding.Mode.MobileOnboardingDeeplink),
onComplete = any(),
)
}
coVerify { clearAppsFlyerDeeplinkUseCase() }
}
@Test
fun `GIVEN deeplink and empty wallets on Home WHEN observe THEN replaceAll hot wallet onboarding and clear`() =
runTest(UnconfinedTestDispatcher()) {
// Arrange
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
val currentRoute = MutableStateFlow<AppRoute?>(AppRoute.Home())
// Act
router.observe(backgroundScope, currentRoute)
advanceUntilIdle()
// Assert — unauthorized path opens onboarding as the root (skips Home) and consumes the deep link.
verify { appRouter.replaceAll(AppRoute.TangemPayHotWalletOnboarding, onComplete = any()) }
coVerify { clearAppsFlyerDeeplinkUseCase() }
}
@Test
fun `GIVEN referral deeplink and empty wallets on Home WHEN observe THEN replaceAll create wallet, not cleared`() =
runTest(UnconfinedTestDispatcher()) {
// Arrange
every { appsFlyerStore.observeNavigationDeeplink() } returns flowOf("referral")
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
val currentRoute = MutableStateFlow<AppRoute?>(AppRoute.Home())
// Act
router.observe(backgroundScope, currentRoute)
advanceUntilIdle()
// Assert — referral install skips stories → hot wallet creation; deep link stays as attribution.
verify {
appRouter.replaceAll(
match<AppRoute> {
it is AppRoute.CreateWalletStart && it.mode == AppRoute.CreateWalletStart.Mode.HotWallet
},
onComplete = any(),
)
}
coVerify(exactly = 0) { clearAppsFlyerDeeplinkUseCase() }
}
@Test
fun `GIVEN no stored deeplink WHEN observe THEN does not evaluate`() = runTest(UnconfinedTestDispatcher()) {
// Arrange
every { appsFlyerStore.observeNavigationDeeplink() } returns flowOf(null)
val currentRoute = MutableStateFlow<AppRoute?>(AppRoute.Wallet)
// Act
router.observe(backgroundScope, currentRoute)
advanceUntilIdle()
// Assert
coVerify(exactly = 0) { userWalletsListRepository.userWalletsSync() }
verify(exactly = 0) { appRouter.push(any(), any()) }
}
@Test
fun `GIVEN feature disabled WHEN observe THEN does not navigate`() = runTest(UnconfinedTestDispatcher()) {
// Arrange
every { featureTogglesManager.isFeatureEnabled(any()) } returns false
val currentRoute = MutableStateFlow<AppRoute?>(AppRoute.Wallet)
// Act
router.observe(backgroundScope, currentRoute)
advanceUntilIdle()
// Assert
coVerify(exactly = 0) { userWalletsListRepository.userWalletsSync() }
verify(exactly = 0) { appRouter.push(any(), any()) }
}
// endregion
}

View file

@ -1,14 +1,11 @@
package com.tangem.tap.common.analytics.appsflyer
import com.appsflyer.deeplink.DeepLink
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.test.core.ProvideTestModels
import com.tangem.test.core.TestAppCoroutineScope
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
@ -19,9 +16,6 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class AppsFlyerReferralParamsHandlerTest {
@ -163,160 +157,44 @@ class AppsFlyerReferralParamsHandlerTest {
data class HandleParamsModel(val params: Map<String?, Any?>, val shouldStore: Boolean)
@Nested
inner class WaitForDeeplink {
private val localStore: AppsFlyerStore = mockk(relaxUnitFun = true)
private val localHandler = AppsFlyerReferralParamsHandler(
appsFlyerStore = localStore,
coroutineScope = TestAppCoroutineScope(),
)
inner class NavigationDeeplink {
@Test
fun `GIVEN cached deeplink WHEN waitForDeeplink THEN returns cached value`() = runTest {
// GIVEN
coEvery {
localStore.getDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
} returns "tpay_mobileonboard"
fun `GIVEN tpay_mobileonboard params WHEN handle THEN navigation deeplink stored`() = runTest {
handler.handle(params = mapOf("deep_link_value" to "tpay_mobileonboard"))
// WHEN
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
// THEN
assertThat(result).isEqualTo("tpay_mobileonboard")
coVerify { appsFlyerStore.storeNavigationDeeplink("tpay_mobileonboard") }
}
@Test
fun `GIVEN no cache and matching deeplink WHEN handleDeeplink then waitForDeeplink THEN returns deeplink value`() = runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) } returns null
fun `GIVEN tpay_mobileonboard deeplink WHEN handleDeeplink THEN navigation deeplink stored`() = runTest {
val deepLink = mockk<DeepLink> {
every { deepLinkValue } returns "tpay_mobileonboard"
every { getStringValue(any()) } returns null
}
// WHEN
localHandler.handleDeeplink(deepLink)
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
handler.handleDeeplink(deepLink)
// THEN
assertThat(result).isEqualTo("tpay_mobileonboard")
coVerify { appsFlyerStore.storeNavigationDeeplink("tpay_mobileonboard") }
}
@Test
fun `GIVEN no cache and non-matching deeplink WHEN handleDeeplink then waitForDeeplink THEN returns null`() = runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) } returns null
fun `GIVEN referral params WHEN handle THEN navigation deeplink stored`() = runTest {
handler.handle(params = mapOf("deep_link_value" to "referral"))
coVerify { appsFlyerStore.storeNavigationDeeplink("referral") }
}
@Test
fun `GIVEN referral deeplink WHEN handleDeeplink THEN navigation deeplink stored`() = runTest {
val deepLink = mockk<DeepLink> {
every { deepLinkValue } returns "referral"
every { getStringValue(any()) } returns null
}
// WHEN
localHandler.handleDeeplink(deepLink)
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
handler.handleDeeplink(deepLink)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN no cache WHEN handleNoDeeplink then waitForDeeplink THEN returns null`() = runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) } returns null
// WHEN
localHandler.handleNoDeeplink()
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
// THEN
assertThat(result).isNull()
}
}
@Nested
inner class WaitForReferralDeeplink {
private val localStore: AppsFlyerStore = mockk(relaxUnitFun = true)
private val localHandler = AppsFlyerReferralParamsHandler(
appsFlyerStore = localStore,
coroutineScope = TestAppCoroutineScope(),
)
@Test
fun `GIVEN cached referral deeplink WHEN waitForDeeplink THEN returns cached value`() = runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns "referral"
// WHEN
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
// THEN
assertThat(result).isEqualTo("referral")
}
@Test
fun `GIVEN no cache and referral deeplink WHEN handleDeeplink then waitForDeeplink THEN returns referral value`() =
runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns null
val deepLink = mockk<DeepLink> {
every { deepLinkValue } returns "referral"
every { getStringValue(any()) } returns SUCCESS_REFCODE
}
// WHEN
localHandler.handleDeeplink(deepLink)
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
// THEN
assertThat(result).isEqualTo("referral")
coVerify { localStore.storeDeeplink(AppsFlyerDeeplinkSource.Referral, "referral") }
}
@Test
fun `GIVEN no cache and non-referral deeplink WHEN handleDeeplink then waitForDeeplink THEN returns null`() =
runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns null
val deepLink = mockk<DeepLink> {
every { deepLinkValue } returns "tpay_mobileonboard"
every { getStringValue(any()) } returns null
}
// WHEN
localHandler.handleDeeplink(deepLink)
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
// THEN
assertThat(result).isNull()
coVerify(inverse = true) { localStore.storeDeeplink(AppsFlyerDeeplinkSource.Referral, any()) }
}
@Test
fun `GIVEN no cache WHEN handleNoDeeplink then waitForDeeplink THEN returns null`() = runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns null
// WHEN
localHandler.handleNoDeeplink()
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN deeplink stored during wait WHEN waitForDeeplink THEN returns stored value`() = runTest {
// GIVEN cache is empty on the initial read but populated (e.g. from conversion-data
// handling) by the time we re-check after awaiting the deferred
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns null andThen "referral"
// WHEN the deferred resolves without a matching value (UDL reported no deep link)
localHandler.handleNoDeeplink()
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
// THEN the value persisted during the wait is preferred
assertThat(result).isEqualTo("referral")
coVerify { appsFlyerStore.storeNavigationDeeplink("referral") }
}
}

View file

@ -518,6 +518,9 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data object FromBannerInSettings : Mode()
@Serializable
data object MobileOnboardingDeeplink : Mode()
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.local.appsflyer
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import kotlinx.coroutines.flow.Flow
interface AppsFlyerStore {
@ -14,20 +15,11 @@ interface AppsFlyerStore {
suspend fun storeUIDIfAbsent(value: String)
suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String?
fun observeNavigationDeeplink(): Flow<String?>
suspend fun storeDeeplink(source: AppsFlyerDeeplinkSource, deeplink: String)
suspend fun getNavigationDeeplink(): String?
suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource)
}
suspend fun storeNavigationDeeplink(deepLinkValue: String)
enum class AppsFlyerDeeplinkSource {
TangemPayHotWalletOnboarding,
Referral,
;
fun toStoreKey() = when (this) {
TangemPayHotWalletOnboarding -> "tangem_pay_hot_wallet_onboarding"
Referral -> "referral"
}
suspend fun clearNavigationDeeplink()
}

View file

@ -9,6 +9,9 @@ import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
internal class DefaultAppsFlyerStore(
private val appPreferencesStore: AppPreferencesStore,
@ -56,24 +59,28 @@ internal class DefaultAppsFlyerStore(
}
}
override suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String? =
appPreferencesStore.getSyncOrNull(stringPreferencesKey(source.toStoreKey()))
override fun observeNavigationDeeplink(): Flow<String?> = appPreferencesStore.data
.map { preferences -> preferences[NAVIGATION_DEEPLINK_KEY] }
.distinctUntilChanged()
override suspend fun storeDeeplink(source: AppsFlyerDeeplinkSource, deeplink: String) {
override suspend fun getNavigationDeeplink(): String? = appPreferencesStore.getSyncOrNull(NAVIGATION_DEEPLINK_KEY)
override suspend fun storeNavigationDeeplink(deepLinkValue: String) {
appPreferencesStore.editData { preferences ->
preferences[stringPreferencesKey(source.toStoreKey())] = deeplink
preferences[NAVIGATION_DEEPLINK_KEY] = deepLinkValue
}
}
override suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) {
override suspend fun clearNavigationDeeplink() {
appPreferencesStore.editData { preferences ->
preferences.remove(stringPreferencesKey(source.toStoreKey()))
preferences.remove(NAVIGATION_DEEPLINK_KEY)
}
}
private companion object {
val UID_KEY = stringPreferencesKey("APPS_FLYER_UID")
val CONVERSION_DATA_KEY = stringPreferencesKey("APPS_FLYER_CONVERSION_DATA")
val NAVIGATION_DEEPLINK_KEY = stringPreferencesKey("appsflyer_navigation_deeplink")
}
}

View file

@ -1,25 +1,18 @@
package com.tangem.data.appsflyer
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
import javax.inject.Inject
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource as StoreDeeplinkSource
internal class DefaultAppsFlyerRepository @Inject constructor(
private val appsFlyerStore: AppsFlyerStore,
) : AppsFlyerRepository {
override suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String? {
return appsFlyerStore.getDeeplink(source.toStoreSource())
override suspend fun getDeeplink(): String? {
return appsFlyerStore.getNavigationDeeplink()
}
override suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) {
appsFlyerStore.clearDeeplink(source.toStoreSource())
}
private fun AppsFlyerDeeplinkSource.toStoreSource() = when (this) {
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> StoreDeeplinkSource.TangemPayHotWalletOnboarding
AppsFlyerDeeplinkSource.Referral -> StoreDeeplinkSource.Referral
override suspend fun clearDeeplink() {
appsFlyerStore.clearNavigationDeeplink()
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.appsflyer
/** Known AppsFlyer navigational deep links, keyed by their `deep_link_value`. */
enum class AppsFlyerDeeplink(val deepLinkValue: String) {
TangemPayMobileOnboarding(deepLinkValue = "tpay_mobileonboard"),
Referral(deepLinkValue = "referral"),
;
companion object {
fun from(deepLinkValue: String?): AppsFlyerDeeplink? = entries.firstOrNull { it.deepLinkValue == deepLinkValue }
}
}

View file

@ -1,6 +0,0 @@
package com.tangem.domain.appsflyer
enum class AppsFlyerDeeplinkSource {
TangemPayHotWalletOnboarding,
Referral,
}

View file

@ -1,10 +1,8 @@
package com.tangem.domain.appsflyer.repository
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
interface AppsFlyerRepository {
suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String?
suspend fun getDeeplink(): String?
suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource)
suspend fun clearDeeplink()
}

View file

@ -1,12 +1,11 @@
package com.tangem.domain.appsflyer.usecase
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
class ClearAppsFlyerDeeplinkUseCase(
private val appsFlyerRepository: AppsFlyerRepository,
) {
suspend operator fun invoke(source: AppsFlyerDeeplinkSource) {
appsFlyerRepository.clearDeeplink(source)
suspend operator fun invoke() {
appsFlyerRepository.clearDeeplink()
}
}

View file

@ -1,6 +1,6 @@
package com.tangem.domain.appsflyer.usecase
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.AppsFlyerDeeplink
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
/**
@ -10,6 +10,6 @@ class IsReferralInstallUseCase(
private val appsFlyerRepository: AppsFlyerRepository,
) {
suspend operator fun invoke(): Boolean {
return appsFlyerRepository.getDeeplink(AppsFlyerDeeplinkSource.Referral) != null
return AppsFlyerDeeplink.from(appsFlyerRepository.getDeeplink()) == AppsFlyerDeeplink.Referral
}
}

View file

@ -13,7 +13,8 @@ interface TangemPayEligibilityManager {
/**
* Returns all compatible user wallets without checking Tangem Pay eligibility, only used when opening deeplink
* Remove after removing [TangemPayOnboardingComponent.Params.Deeplink]
* Remove after removing [TangemPayOnboardingComponent.Params.Deeplink] and
* [TangemPayOnboardingComponent.Params.MobileOnboardingDeeplink]
* */
suspend fun getPossibleWalletsIds(shouldExcludePaeraCustomers: Boolean): List<UserWalletId>

View file

@ -23,6 +23,9 @@ interface TangemPayOnboardingComponent : ComposableContentComponent {
data object FromBannerOnMain : Params()
data object FromBannerInSettings : Params()
/** Like [Deeplink] but for the `tpay_mobileonboard` AppsFlyer link: skips backend deeplink validation. */
data object MobileOnboardingDeeplink : Params()
}
interface Factory : ComponentFactory<Params, TangemPayOnboardingComponent>

View file

@ -10,8 +10,6 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.dialog.Dialogs
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.hotwallet.IsHotWalletCreationSupported
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
import com.tangem.domain.wallets.usecase.CreateHotWalletUseCase
@ -27,13 +25,11 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class TangemPayHotWalletOnboardingModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val createHotWalletUseCase: CreateHotWalletUseCase,
private val isHotWalletCreationSupported: IsHotWalletCreationSupported,
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
private val router: Router,
private val uiMessageSender: UiMessageSender,
) : Model() {
@ -61,7 +57,6 @@ internal class TangemPayHotWalletOnboardingModel @Inject constructor(
uiMessageSender.send(
Dialogs.hotWalletCreationNotSupportedDialog(isHotWalletCreationSupported.getLeastVersionName()),
)
modelScope.launch { clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) }
router.replaceCurrent(AppRoute.Home())
return
}
@ -75,8 +70,6 @@ internal class TangemPayHotWalletOnboardingModel @Inject constructor(
).getOrElse { throw it }
TangemLogger.i("[TangemPay][HWO]Hot wallet created")
clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
router.replaceAll(
AppRoute.CreateWalletBackup(
userWalletId = userWallet.walletId,

View file

@ -74,8 +74,10 @@ internal class TangemPayOnboardingModel @Inject constructor(
is TangemPayOnboardingComponent.Params.HotWalletOnboarding -> {
startOnboarding(userWalletId = params.userWalletId)
}
// FromBanner* and mobile-onboard skip the backend validation that Params.Deeplink performs.
is TangemPayOnboardingComponent.Params.FromBannerInSettings,
is TangemPayOnboardingComponent.Params.FromBannerOnMain,
is TangemPayOnboardingComponent.Params.MobileOnboardingDeeplink,
-> showOnboarding()
}
}
@ -144,7 +146,9 @@ internal class TangemPayOnboardingModel @Inject constructor(
private fun onGetCardClick() {
analytics.send(TangemPayAnalyticsEvents.GetCardClicked())
if (params is TangemPayOnboardingComponent.Params.Deeplink) {
if (params is TangemPayOnboardingComponent.Params.Deeplink ||
params is TangemPayOnboardingComponent.Params.MobileOnboardingDeeplink
) {
modelScope.launch {
openWalletSelectorIfNeeds(
walletsIds = eligibilityManager.getPossibleWalletsIds(shouldExcludePaeraCustomers = true),
@ -249,6 +253,7 @@ internal class TangemPayOnboardingModel @Inject constructor(
is TangemPayOnboardingComponent.Params.Deeplink,
is TangemPayOnboardingComponent.Params.ContinueOnboarding,
is TangemPayOnboardingComponent.Params.HotWalletOnboarding,
is TangemPayOnboardingComponent.Params.MobileOnboardingDeeplink,
-> null
}
}

View file

@ -7,8 +7,6 @@ import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.hotwallet.IsHotWalletCreationSupported
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -17,17 +15,21 @@ import com.tangem.hot.sdk.model.HotAuth
import com.tangem.hot.sdk.model.MnemonicType
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class TangemPayHotWalletOnboardingModelTest {
private val createHotWalletUseCase: CreateHotWalletUseCase = mockk()
private val isHotWalletCreationSupported: IsHotWalletCreationSupported = mockk() {
every { getLeastVersionName() } returns "Android 10"
}
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase = mockk()
private val router: Router = mockk(relaxed = true)
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
@ -41,11 +43,16 @@ internal class TangemPayHotWalletOnboardingModelTest {
@Test
fun `WHEN onTermsClick THEN navigate to Disclaimer`() = runTest {
val model = createModel()
// Arrange
val model = createModel(testScope = this)
// Act
model.uiState.value.onTermsClick.invoke()
advanceUntilIdle()
// Assert
verify { router.push(AppRoute.Disclaimer(isTosAccepted = true)) }
model.onDestroy()
}
}
@ -55,29 +62,34 @@ internal class TangemPayHotWalletOnboardingModelTest {
@Test
fun `GIVEN hot wallet creation not supported WHEN onGetCardClick THEN wallet creation not attempted`() =
runTest {
// Arrange
every { isHotWalletCreationSupported() } returns false
coEvery { clearAppsFlyerDeeplinkUseCase(any()) } just Runs
val model = createModel(testScope = this)
val model = createModel()
// Act
model.uiState.value.onGetCardClick.invoke()
advanceUntilIdle()
// Assert
verify { uiMessageSender.send(any()) }
verify { router.replaceCurrent(AppRoute.Home()) }
coVerify { clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) }
coVerify(exactly = 0) { createHotWalletUseCase(any(), any()) }
model.onDestroy()
}
@Test
fun `GIVEN hot wallet supported AND wallet creation succeeds WHEN onGetCardClick THEN deeplink cleared AND navigate to CreateWalletBackup`() =
fun `GIVEN hot wallet supported AND wallet creation succeeds WHEN onGetCardClick THEN navigate to CreateWalletBackup`() =
runTest {
// Arrange
every { isHotWalletCreationSupported() } returns true
coEvery { createHotWalletUseCase(HotAuth.NoAuth, MnemonicType.Words12) } returns testUserWallet.right()
coEvery { clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) } just Runs
val model = createModel(testScope = this)
val model = createModel()
// Act
model.uiState.value.onGetCardClick.invoke()
advanceUntilIdle()
coVerify { clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) }
// Assert
verify {
router.replaceAll(
match { route ->
@ -87,34 +99,49 @@ internal class TangemPayHotWalletOnboardingModelTest {
},
)
}
model.onDestroy()
}
@Test
fun `GIVEN hot wallet supported AND wallet creation fails WHEN onGetCardClick THEN error dialog sent`() =
runTest {
// Arrange
every { isHotWalletCreationSupported() } returns true
coEvery {
createHotWalletUseCase(HotAuth.NoAuth, MnemonicType.Words12)
} returns RuntimeException("error").left()
val model = createModel(testScope = this)
val model = createModel()
// Act
model.uiState.value.onGetCardClick.invoke()
advanceUntilIdle()
// Assert
assertThat(model.uiState.value.isLoading).isFalse()
verify { uiMessageSender.send(match<DialogMessage> { true }) }
coVerify(exactly = 0) { clearAppsFlyerDeeplinkUseCase(any()) }
verify(exactly = 0) { router.replaceAll(*anyVararg()) }
model.onDestroy()
}
}
private fun createModel(): TangemPayHotWalletOnboardingModel {
private fun createModel(testScope: TestScope): TangemPayHotWalletOnboardingModel {
return TangemPayHotWalletOnboardingModel(
dispatchers = TestingCoroutineDispatcherProvider(),
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
createHotWalletUseCase = createHotWalletUseCase,
isHotWalletCreationSupported = isHotWalletCreationSupported,
clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase,
router = router,
uiMessageSender = uiMessageSender,
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}

View file

@ -133,6 +133,39 @@ internal class TangemPayOnboardingModelTest {
model.onDestroy()
}
@Test
fun `GIVEN MobileOnboardingDeeplink WHEN model created THEN shows onboarding without validating deeplink`() =
runTest {
// Act
val model = createModel(TangemPayOnboardingComponent.Params.MobileOnboardingDeeplink)
advanceUntilIdle()
// Assert
assertThat(model.uiState.value).isInstanceOf(TangemPayOnboardingScreenState.Content::class.java)
coVerify(exactly = 0) { repository.validateDeeplink(any()) }
model.onDestroy()
}
@Test
fun `GIVEN MobileOnboardingDeeplink WHEN get card clicked THEN uses possible wallets ignoring eligibility`() =
runTest {
// Arrange
coEvery {
eligibilityManager.getPossibleWalletsIds(shouldExcludePaeraCustomers = true)
} returns emptyList()
val model = createModel(TangemPayOnboardingComponent.Params.MobileOnboardingDeeplink)
advanceUntilIdle()
val content = model.uiState.value as TangemPayOnboardingScreenState.Content
// Act
content.buttonConfig.onClick.invoke()
advanceUntilIdle()
// Assert
coVerify { eligibilityManager.getPossibleWalletsIds(shouldExcludePaeraCustomers = true) }
model.onDestroy()
}
private fun TestScope.createModel(params: TangemPayOnboardingComponent.Params): TangemPayOnboardingModel {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TangemPayOnboardingModel(