diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index 6e37802279..89360fb5fd 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -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()) 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 796b6804ef..6d82389d31 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 @@ -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}") } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeeplinkRouter.kt b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeeplinkRouter.kt new file mode 100644 index 0000000000..7b560adc5e --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeeplinkRouter.kt @@ -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) { + 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 \ 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 index c7edd65901..86f929f73e 100644 --- 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 @@ -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() - fun handle(params: Map) { 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" 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 772c294d3b..d400f9bbb6 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 @@ -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" + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index baf3cd3453..645eebc302 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -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) } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt index 637f3befc1..1b27cfa27a 100644 --- a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt +++ b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt @@ -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(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( diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index cb137f937d..f34e099e0e 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -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, ) 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 2a3e53fea0..bfa543ee48 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 @@ -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()) } } } diff --git a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeeplinkRouterTest.kt b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeeplinkRouterTest.kt new file mode 100644 index 0000000000..006a465704 --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeeplinkRouterTest.kt @@ -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()) + } + + // region idle entry point + + @ParameterizedTest + @ProvideTestModels + fun isIdle(model: IdleModel) { + assertThat(isIdleEntryPoint(model.currentRoute)).isEqualTo(model.expected) + } + + private fun provideTestModels(): List = 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()) + val currentRoute = MutableStateFlow(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.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.Home()) + + // Act + router.observe(backgroundScope, currentRoute) + advanceUntilIdle() + + // Assert — referral install skips stories → hot wallet creation; deep link stays as attribution. + verify { + appRouter.replaceAll( + match { + 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.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.Wallet) + + // Act + router.observe(backgroundScope, currentRoute) + advanceUntilIdle() + + // Assert + coVerify(exactly = 0) { userWalletsListRepository.userWalletsSync() } + verify(exactly = 0) { appRouter.push(any(), any()) } + } + + // endregion +} \ 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 index 206f23f70a..29a681e6ba 100644 --- 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 @@ -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, 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 { 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 { 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 { - 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 { - 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") } } } diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index c358ce8fe7..d0279e83a4 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -518,6 +518,9 @@ sealed class AppRoute(val path: String) : Route { @Serializable data object FromBannerInSettings : Mode() + + @Serializable + data object MobileOnboardingDeeplink : Mode() } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerStore.kt index b4c8e2018d..c79f047dab 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerStore.kt @@ -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 - 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() } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerStore.kt index 5d7fcfb165..6ea0464d5e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerStore.kt @@ -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 = 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") } } diff --git a/data/appsflyer/src/main/java/com/tangem/data/appsflyer/DefaultAppsFlyerRepository.kt b/data/appsflyer/src/main/java/com/tangem/data/appsflyer/DefaultAppsFlyerRepository.kt index 680731d723..d20e290213 100644 --- a/data/appsflyer/src/main/java/com/tangem/data/appsflyer/DefaultAppsFlyerRepository.kt +++ b/data/appsflyer/src/main/java/com/tangem/data/appsflyer/DefaultAppsFlyerRepository.kt @@ -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() } } \ No newline at end of file diff --git a/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/AppsFlyerDeeplink.kt b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/AppsFlyerDeeplink.kt new file mode 100644 index 0000000000..de720db13b --- /dev/null +++ b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/AppsFlyerDeeplink.kt @@ -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 } + } +} \ No newline at end of file diff --git a/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/AppsFlyerDeeplinkSource.kt b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/AppsFlyerDeeplinkSource.kt deleted file mode 100644 index d516a1ef10..0000000000 --- a/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/AppsFlyerDeeplinkSource.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.domain.appsflyer - -enum class AppsFlyerDeeplinkSource { - TangemPayHotWalletOnboarding, - Referral, -} \ No newline at end of file diff --git a/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/repository/AppsFlyerRepository.kt b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/repository/AppsFlyerRepository.kt index 3ed7bde4d3..cdc0654e02 100644 --- a/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/repository/AppsFlyerRepository.kt +++ b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/repository/AppsFlyerRepository.kt @@ -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() } \ No newline at end of file diff --git a/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/usecase/ClearAppsFlyerDeeplinkUseCase.kt b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/usecase/ClearAppsFlyerDeeplinkUseCase.kt index b0ddd7ec25..0b51525d4a 100644 --- a/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/usecase/ClearAppsFlyerDeeplinkUseCase.kt +++ b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/usecase/ClearAppsFlyerDeeplinkUseCase.kt @@ -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() } } \ No newline at end of file diff --git a/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/usecase/IsReferralInstallUseCase.kt b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/usecase/IsReferralInstallUseCase.kt index 398ada8802..08a53c3387 100644 --- a/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/usecase/IsReferralInstallUseCase.kt +++ b/domain/appsflyer/src/main/kotlin/com/tangem/domain/appsflyer/usecase/IsReferralInstallUseCase.kt @@ -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 } } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayEligibilityManager.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayEligibilityManager.kt index f193824693..37d64a2c40 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayEligibilityManager.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayEligibilityManager.kt @@ -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 diff --git a/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayOnboardingComponent.kt b/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayOnboardingComponent.kt index 171d4220c8..9546e9ce17 100644 --- a/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayOnboardingComponent.kt +++ b/features/tangempay/onboarding/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayOnboardingComponent.kt @@ -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 diff --git a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModel.kt b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModel.kt index acead48f67..939f767c70 100644 --- a/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModel.kt +++ b/features/tangempay/onboarding/impl/src/main/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModel.kt @@ -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, 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 869fc11d95..ff6db15c50 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 @@ -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 } } \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModelTest.kt b/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModelTest.kt index cb1af5576e..e93e4a059c 100644 --- a/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModelTest.kt +++ b/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/hotwallet/TangemPayHotWalletOnboardingModelTest.kt @@ -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 { 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, + ) + } } \ No newline at end of file diff --git a/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModelTest.kt b/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModelTest.kt index ca82d57966..baa2c6dce9 100644 --- a/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModelTest.kt +++ b/features/tangempay/onboarding/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayOnboardingModelTest.kt @@ -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(