Updated on 2026-08-14
This commit is contained in:
parent
a2c8e7e95a
commit
505413483e
26 changed files with 467 additions and 321 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -518,6 +518,9 @@ sealed class AppRoute(val path: String) : Route {
|
|||
|
||||
@Serializable
|
||||
data object FromBannerInSettings : Mode()
|
||||
|
||||
@Serializable
|
||||
data object MobileOnboardingDeeplink : Mode()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
package com.tangem.domain.appsflyer
|
||||
|
||||
enum class AppsFlyerDeeplinkSource {
|
||||
TangemPayHotWalletOnboarding,
|
||||
Referral,
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue