Updated on 2026-08-14

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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