diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4fdc8ffde8..e6002f64e0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -108,6 +108,9 @@ configurations.androidTestImplementation { exclude(module = "protobuf-lite") } +tasks.withType().configureEach { + useJUnitPlatform() +} dependencies { implementation(projects.domain.legacy) @@ -363,6 +366,8 @@ dependencies { implementation(deps.googlePlay.review) implementation(deps.googlePlay.review.ktx) implementation(deps.googlePlay.services.wallet) + implementation(deps.googlePlay.services) + implementation(deps.googlePlay.advertising) coreLibraryDesugaring(deps.desugar) implementation(deps.timber) implementation(deps.reKotlin) @@ -393,11 +398,10 @@ dependencies { implementation(files("libs/dexprotector-annotations.jar")) /** Testing libraries */ + testImplementation(projects.test.core) testImplementation(projects.common.test) - testImplementation(deps.test.coroutine) testImplementation(deps.test.junit) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) + testRuntimeOnly(deps.test.junit5.engine) androidTestImplementation(deps.test.junit.android) androidTestImplementation(deps.test.espresso) androidTestImplementation(deps.test.espresso.intents) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bb289cd06c..97a76b6ac8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,9 +11,15 @@ - - - + + + @@ -36,7 +42,6 @@ @@ -286,9 +292,20 @@ + android:path="/pay-app" + android:scheme="https" /> + + + + + + + + + @@ -337,6 +354,6 @@ - - + + diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index 59dd05efbe..c40ccb2d5a 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -46,6 +46,7 @@ import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.hot.sdk.TangemHotSdk import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler +import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient import com.tangem.tap.common.log.TangemAppLoggerInitializer import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles import com.tangem.tap.proxy.AppStateHolder @@ -157,4 +158,6 @@ interface ApplicationEntryPoint { fun getTrackingContextProxy(): TrackingContextProxy fun getABTestsManager(): ABTestsManager + + fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 52be79032f..4e83789e83 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -66,6 +66,7 @@ import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler import com.tangem.tap.common.analytics.handlers.amplitude.AmplitudeAnalyticsHandler import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerAnalyticsHandler +import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandler import com.tangem.tap.common.images.createCoilImageLoader import com.tangem.tap.common.log.TangemAppLoggerInitializer @@ -248,6 +249,9 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private val abTestsManager: ABTestsManager get() = entryPoint.getABTestsManager() + private val appsFlyerClientFactory: AppsFlyerClient.Factory + get() = entryPoint.getAppsFlyerClientFactory() + // endregion private val appScope = MainScope() @@ -421,7 +425,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. val factory = AnalyticsFactory() factory.addHandlerBuilder(AmplitudeAnalyticsHandler.Builder()) factory.addHandlerBuilder(FirebaseAnalyticsHandler.Builder()) - factory.addHandlerBuilder(AppsFlyerAnalyticsHandler.Builder()) + factory.addHandlerBuilder(AppsFlyerAnalyticsHandler.Builder(appsFlyerClientFactory)) factory.addFilter(oneTimeEventFilter) factory.addFilter(AppsFlyerEventFilter()) diff --git a/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt new file mode 100644 index 0000000000..ae2547a4a9 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListener.kt @@ -0,0 +1,27 @@ +package com.tangem.tap.common.analytics.appsflyer + +import com.appsflyer.deeplink.DeepLinkListener +import com.appsflyer.deeplink.DeepLinkResult +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppsFlyerDeepLinkListener @Inject constructor( + private val referralParamsHandler: AppsFlyerReferralParamsHandler, +) : DeepLinkListener { + + override fun onDeepLinking(p0: DeepLinkResult) { + when (p0.status) { + DeepLinkResult.Status.FOUND -> { + referralParamsHandler.handle(deepLink = p0.deepLink) + } + DeepLinkResult.Status.NOT_FOUND -> { + Timber.i("No deep link found") + } + DeepLinkResult.Status.ERROR -> { + Timber.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/AppsFlyerReferralParamsHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandler.kt new file mode 100644 index 0000000000..21c04dbcac --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandler.kt @@ -0,0 +1,86 @@ +package com.tangem.tap.common.analytics.appsflyer + +import com.appsflyer.deeplink.DeepLink +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +@Singleton +class AppsFlyerReferralParamsHandler @Inject constructor( + private val appsFlyerConversionStore: AppsFlyerConversionStore, + dispatchers: CoroutineDispatcherProvider, +) { + + private val coroutineScope = CoroutineScope(dispatchers.io + SupervisorJob()) + private val mutex = Mutex() + + fun handle(deepLink: DeepLink) { + handle( + deepLinkValue = deepLink.deepLinkValue, + deepLinkSub1 = deepLink.getStringValue(DEEP_LINK_SUB_1), + deepLinkSub2 = deepLink.getStringValue(DEEP_LINK_SUB_2), + ) + } + + fun handle(params: Map) { + handle( + deepLinkValue = params[DEEP_LINK_VALUE] as? String, + deepLinkSub1 = params[DEEP_LINK_SUB_1] as? String, + deepLinkSub2 = params[DEEP_LINK_SUB_2] as? String, + ) + } + + private fun handle(deepLinkValue: String?, deepLinkSub1: String?, deepLinkSub2: String?) { + if (deepLinkValue != REFERRAL_DEEP_LINK_VALUE) { + Timber.i("Ignoring deep link with value: ${deepLinkValue ?: "null"}") + return + } + + @Suppress("NullableToStringCall") + Timber.i("refcode=$deepLinkSub1\ncampaign=$deepLinkSub2") + + if (!isValidParam(deepLinkSub1)) { + Timber.e("Deeplink conversion data is invalid") + return + } + + storeConversionData(refcode = deepLinkSub1, campaign = deepLinkSub2) + } + + @OptIn(ExperimentalContracts::class) + private fun isValidParam(value: String?): Boolean { + contract { + returns(true) implies (value != null) + } + + return value != null && value.isNotBlank() && !value.equals("null", ignoreCase = true) + } + + private fun storeConversionData(refcode: String, campaign: String?) { + coroutineScope.launch { + mutex.withLock { + appsFlyerConversionStore.storeIfAbsent( + value = AppsFlyerConversionData(refcode = refcode, campaign = campaign), + ) + } + } + } + + private companion object { + + const val REFERRAL_DEEP_LINK_VALUE = "referral" + const val DEEP_LINK_VALUE = "deep_link_value" + const val DEEP_LINK_SUB_1 = "deep_link_sub1" + const val DEEP_LINK_SUB_2 = "deep_link_sub2" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/TangemAFConversionListener.kt b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/TangemAFConversionListener.kt new file mode 100644 index 0000000000..a051eefeb9 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/appsflyer/TangemAFConversionListener.kt @@ -0,0 +1,32 @@ +package com.tangem.tap.common.analytics.appsflyer + +import com.appsflyer.AppsFlyerConversionListener +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TangemAFConversionListener @Inject constructor( + private val referralParamsHandler: AppsFlyerReferralParamsHandler, +) : AppsFlyerConversionListener { + + override fun onConversionDataSuccess(p0: Map?) { + Timber.i("AppsFlyer conversion data success: ${p0.orEmpty()}") + + if (p0 == null) return + + referralParamsHandler.handle(params = p0) + } + + override fun onConversionDataFail(p0: String?) { + Timber.e("AppsFlyer conversion data failure: ${p0.orEmpty()}") + } + + override fun onAppOpenAttribution(p0: Map?) { + Timber.i("AppsFlyer app open attribution: ${p0.orEmpty()}") + } + + override fun onAttributionFailure(p0: String?) { + Timber.e("AppsFlyer attribution failure: ${p0.orEmpty()}") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt index 231660ff27..77227aa7c2 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsClient.kt @@ -1,64 +1,42 @@ package com.tangem.tap.common.analytics.handlers.appsflyer import android.content.Context -import com.appsflyer.AppsFlyerConversionListener import com.appsflyer.AppsFlyerLib import com.appsflyer.attribution.AppsFlyerRequestListener import com.tangem.core.analytics.api.EventLogger import com.tangem.core.analytics.api.UserIdHolder +import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeepLinkListener +import com.tangem.tap.common.analytics.appsflyer.TangemAFConversionListener import com.tangem.tap.common.analytics.handlers.firebase.UnderscoreAnalyticsEventConverter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext import timber.log.Timber interface AppsFlyerAnalyticsClient : EventLogger, UserIdHolder -internal class AppsFlyerClient( - private val context: Context, - key: String, +class AppsFlyerClient @AssistedInject constructor( + @Assisted apiKey: String, + @ApplicationContext private val context: Context, + appsFlyerDeepLinkListener: AppsFlyerDeepLinkListener, + tangemAFConversionListener: TangemAFConversionListener, ) : AppsFlyerAnalyticsClient { private val appsFlyerLib: AppsFlyerLib = AppsFlyerLib.getInstance() private val eventConverter = UnderscoreAnalyticsEventConverter() - private val logEventListener = object : AppsFlyerRequestListener { - override fun onSuccess() { - Timber.tag("AppsFlyerClient").i("AppsFlyerRequestListener send") - } - - override fun onError(p0: Int, p1: String) { - Timber.tag("AppsFlyerClient").e("AppsFlyerRequestListener onError: $p0, $p1") - } - } init { - val conversionListener = object : AppsFlyerConversionListener { - override fun onConversionDataSuccess(p0: Map?) { - Timber.i("AppsFlyer conversion data success: ${p0.orEmpty()}") - } - override fun onConversionDataFail(p0: String?) { - Timber.e("AppsFlyer conversion data failure: ${p0.orEmpty()}") - } - override fun onAppOpenAttribution(p0: Map?) { - Timber.i("AppsFlyer app open attribution: ${p0.orEmpty()}") - } - override fun onAttributionFailure(p0: String?) { - Timber.e("AppsFlyer attribution failure: ${p0.orEmpty()}") - } - } - - appsFlyerLib.run { + with(appsFlyerLib) { setAppId(context.packageName) setDebugLog(true) - init(key, conversionListener, context) - Timber.i("Starting AppsFlyer SDK") - start(context, key, object : AppsFlyerRequestListener { - override fun onSuccess() { - Timber.i("AppsFlyer initialized successfully") - } + subscribeForDeepLink(appsFlyerDeepLinkListener) - override fun onError(p0: Int, p1: String) { - Timber.e("AppsFlyer initialization error: $p0, $p1") - } - }) + init(apiKey, tangemAFConversionListener, context) + + Timber.i("Starting AppsFlyer SDK") + start(context, apiKey, InitializationListener) Timber.i("AppsFlyer SDK started") } } @@ -77,7 +55,32 @@ internal class AppsFlyerClient( context, event, eventConverter.convertEventParams(params), - logEventListener, + LogEventListener, ) } + + private object InitializationListener : AppsFlyerRequestListener { + override fun onSuccess() { + Timber.d("AppsFlyer initialized successfully") + } + + override fun onError(p0: Int, p1: String) { + Timber.e("AppsFlyer initialization error: $p0, $p1") + } + } + + private object LogEventListener : AppsFlyerRequestListener { + override fun onSuccess() { + Timber.tag("AppsFlyerClient").i("AppsFlyerRequestListener send") + } + + override fun onError(p0: Int, p1: String) { + Timber.tag("AppsFlyerClient").e("AppsFlyerRequestListener onError: $p0, $p1") + } + } + + @AssistedFactory + interface Factory { + fun create(apiKey: String): AppsFlyerClient + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsHandler.kt index 26675e0fef..40dfd9b37a 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsHandler.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsflyer/AppsFlyerAnalyticsHandler.kt @@ -47,7 +47,10 @@ class AppsFlyerAnalyticsHandler( const val ID = "AppsFlyer" } - class Builder : AnalyticsHandlerBuilder { + internal class Builder( + private val appsFlyerClientFactory: AppsFlyerClient.Factory, + ) : AnalyticsHandlerBuilder { + init { Timber.tag("AppsFlyer").i("AppsFlyer Analytics Handler Builder created") } @@ -58,7 +61,7 @@ class AppsFlyerAnalyticsHandler( AppsFlyerLogClient(data.jsonConverter) } else { Timber.tag("AppsFlyer").i("AppsFlyer log disabled, real client created") - AppsFlyerClient(data.application, data.config.appsFlyerApiKey) + appsFlyerClientFactory.create(apiKey = data.config.appsFlyerApiKey) }, ) } diff --git a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt new file mode 100644 index 0000000000..20f770e186 --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerDeepLinkListenerTest.kt @@ -0,0 +1,89 @@ +package com.tangem.tap.common.analytics.appsflyer + +import com.appsflyer.deeplink.DeepLink +import com.appsflyer.deeplink.DeepLinkResult +import com.tangem.test.core.ProvideTestModels +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AppsFlyerDeepLinkListenerTest { + + private val referralParamsHandler: AppsFlyerReferralParamsHandler = mockk() + private val listener = AppsFlyerDeepLinkListener( + referralParamsHandler = referralParamsHandler, + ) + + @AfterEach + fun tearDown() { + clearMocks(referralParamsHandler) + } + + @ParameterizedTest + @ProvideTestModels + fun onDeepLinking(model: OnDeepLinkingModel) = runTest { + if (model.shouldHandle) { + every { referralParamsHandler.handle(deepLink = model.deepLinkResult.deepLink) } just Runs + } + + listener.onDeepLinking(p0 = model.deepLinkResult) + + if (model.shouldHandle) { + coVerify { referralParamsHandler.handle(deepLink = model.deepLinkResult.deepLink) } + } else { + coVerify(inverse = true) { referralParamsHandler.handle(deepLink = any()) } + } + } + + private fun provideTestModels(): List { + return listOf( + // DeepLinkResult.Status.NOT_FOUND + OnDeepLinkingModel( + deepLinkResult = DeepLinkResult(null, null), + shouldHandle = false, + ), + // DeepLinkResult.Status.ERROR + OnDeepLinkingModel( + deepLinkResult = DeepLinkResult(null, DeepLinkResult.Error.NETWORK), + shouldHandle = false, + ), + // DeepLinkResult.Status.FOUND + OnDeepLinkingModel( + deepLinkResult = createFoundDeepLink( + deepLinkValue = "deep_link_value", + refcode = "refcode", + campaign = "campaign", + ), + shouldHandle = true, + ), + ) + } + + data class OnDeepLinkingModel( + val deepLinkResult: DeepLinkResult, + val shouldHandle: Boolean, + ) + + private fun createFoundDeepLink( + deepLinkValue: String, + refcode: String? = null, + campaign: String? = null, + ): DeepLinkResult { + val deeplink = mockk { + every { this@mockk.deepLinkValue } returns deepLinkValue + every { this@mockk.getStringValue("deep_link_sub1") } returns refcode + every { this@mockk.getStringValue("deep_link_sub2") } returns campaign + } + + return mockk { + every { this@mockk.status } returns DeepLinkResult.Status.FOUND + every { this@mockk.deepLink } returns deeplink + } + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt new file mode 100644 index 0000000000..41e17cbddc --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/AppsFlyerReferralParamsHandlerTest.kt @@ -0,0 +1,164 @@ +package com.tangem.tap.common.analytics.appsflyer + +import com.appsflyer.deeplink.DeepLink +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.test.core.ProvideTestModels +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AppsFlyerReferralParamsHandlerTest { + + private val appsFlyerConversionStore: AppsFlyerConversionStore = mockk(relaxUnitFun = true) + private val handler = AppsFlyerReferralParamsHandler( + appsFlyerConversionStore = appsFlyerConversionStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @AfterEach + fun tearDown() { + clearMocks(appsFlyerConversionStore) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class HandleDeepLink { + + @ParameterizedTest + @ProvideTestModels + fun handle(model: HandleDeepLinkModel) = runTest { + handler.handle(deepLink = model.deepLink) + + if (model.shouldStore) { + val value = AppsFlyerConversionData(refcode = SUCCESS_REFCODE, campaign = SUCCESS_CAMPAIGN) + coVerify { appsFlyerConversionStore.storeIfAbsent(value = value) } + } else { + coVerify(inverse = true) { appsFlyerConversionStore.storeIfAbsent(value = any()) } + } + } + + private fun provideTestModels(): List { + return listOf( + HandleDeepLinkModel( + deepLink = createDeepLink( + deepLinkValue = "referral", + refcode = SUCCESS_REFCODE, + campaign = SUCCESS_CAMPAIGN, + ), + shouldStore = true, + ), + HandleDeepLinkModel( + deepLink = createDeepLink(deepLinkValue = "some_other_deep_link_value"), + shouldStore = false, + ), + HandleDeepLinkModel( + deepLink = createDeepLink(deepLinkValue = ""), + shouldStore = false, + ), + HandleDeepLinkModel( + deepLink = createDeepLink( + deepLinkValue = "referral", + refcode = "", + campaign = SUCCESS_CAMPAIGN, + ), + shouldStore = false, + ), + HandleDeepLinkModel( + deepLink = createDeepLink( + deepLinkValue = "referral", + refcode = null, + campaign = SUCCESS_CAMPAIGN, + ), + shouldStore = false, + ), + ) + } + + private fun createDeepLink( + deepLinkValue: String, + refcode: String? = null, + campaign: String? = null, + ): DeepLink { + return mockk { + every { this@mockk.deepLinkValue } returns deepLinkValue + every { this@mockk.getStringValue("deep_link_sub1") } returns refcode + every { this@mockk.getStringValue("deep_link_sub2") } returns campaign + } + } + } + + data class HandleDeepLinkModel(val deepLink: DeepLink, val shouldStore: Boolean) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class HandleParams { + + @ParameterizedTest + @ProvideTestModels + fun handle(model: HandleParamsModel) = runTest { + handler.handle(params = model.params) + + if (model.shouldStore) { + val value = AppsFlyerConversionData(refcode = SUCCESS_REFCODE, campaign = SUCCESS_CAMPAIGN) + coVerify { appsFlyerConversionStore.storeIfAbsent(value = value) } + } else { + coVerify(inverse = true) { appsFlyerConversionStore.storeIfAbsent(value = any()) } + } + } + + private fun provideTestModels(): List { + return listOf( + HandleParamsModel( + params = mapOf( + "deep_link_value" to "referral", + "deep_link_sub1" to SUCCESS_REFCODE, + "deep_link_sub2" to SUCCESS_CAMPAIGN, + ), + shouldStore = true, + ), + HandleParamsModel( + params = mapOf("deep_link_value" to "some_other_deep_link_value"), + shouldStore = false, + ), + HandleParamsModel( + params = mapOf("deep_link_value" to ""), + shouldStore = false, + ), + HandleParamsModel( + params = mapOf( + "deep_link_value" to "referral", + "deep_link_sub1" to "", + "deep_link_sub2" to SUCCESS_CAMPAIGN, + ), + shouldStore = false, + ), + HandleParamsModel( + params = mapOf( + "deep_link_value" to "referral", + "deep_link_sub2" to SUCCESS_CAMPAIGN, + ), + shouldStore = false, + ), + ) + } + } + + data class HandleParamsModel(val params: Map, val shouldStore: Boolean) + + private companion object Companion { + const val SUCCESS_REFCODE = "valid_refcode" + const val SUCCESS_CAMPAIGN = "valid_campaign" + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/TangemAFConversionListenerTest.kt b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/TangemAFConversionListenerTest.kt new file mode 100644 index 0000000000..f66b08aa16 --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/common/analytics/appsflyer/TangemAFConversionListenerTest.kt @@ -0,0 +1,63 @@ +package com.tangem.tap.common.analytics.appsflyer + +import com.tangem.test.core.ProvideTestModels +import io.mockk.clearMocks +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TangemAFConversionListenerTest { + + private val referralParamsHandler: AppsFlyerReferralParamsHandler = mockk(relaxUnitFun = true) + private val listener = TangemAFConversionListener( + referralParamsHandler = referralParamsHandler, + ) + + @AfterEach + fun tearDown() { + clearMocks(referralParamsHandler) + } + + @ParameterizedTest + @ProvideTestModels + fun onConversionDataSuccess(model: OnConversionDataSuccessModel) = runTest { + listener.onConversionDataSuccess(p0 = model.params) + + if (model.shouldHandle) { + coVerify { referralParamsHandler.handle(params = model.params!!) } + } else { + coVerify(inverse = true) { referralParamsHandler.handle(params = any()) } + } + } + + private fun provideTestModels(): List { + return listOf( + OnConversionDataSuccessModel( + params = null, + shouldHandle = false, + ), + OnConversionDataSuccessModel( + params = emptyMap(), + shouldHandle = true, + ), + OnConversionDataSuccessModel( + params = mapOf( + "deep_link_value" to "referral", + ), + shouldHandle = true, + ), + ) + } + + data class OnConversionDataSuccessModel( + val params: Map?, + val shouldHandle: Boolean, + ) +} \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt index 575be52e80..630b68ded8 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt @@ -12,5 +12,7 @@ object DeeplinkConst { const val DERIVATION_PATH_KEY = "derivation_path" const val TRANSACTION_ID_KEY = "transaction_id" const val PROMO_CODE_KEY = "promo_code" + const val REF_KEY = "ref" + const val CAMPAIGN_KEY = "campaign" const val NAME_KEY = "name" } \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index aaa1656036..1c24b1d40c 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -41,7 +41,7 @@ }, { "name": "HOT_WALLET_VISIBLE", - "version": "undefined" + "version": "5.32.1" }, { "name": "TANGEM_PAY_ENABLED", diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index aec86d12d2..a3b38cb1f0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -56,6 +56,7 @@ interface TangemTechApi { @Body userTokens: UserTokensResponse, ): ApiResponse + // region Referral /** Returns referral status by [walletId] */ @GET("v1/referral/{walletId}") suspend fun getReferralStatus(@Path("walletId") walletId: String): ApiResponse @@ -64,6 +65,10 @@ interface TangemTechApi { @POST("v1/referral") suspend fun startReferral(@Body startReferralBody: StartReferralBody): ApiResponse + @POST("v1/referral/bind-wallets-by-code") + suspend fun bindWalletsByReferralCode(@Body body: BindWalletsByReferralCodeBody): ApiResponse + // endregion + @GET("v1/quotes") suspend fun getQuotes( @Query("currencyId") currencyId: String, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt index 0e65ff47c9..8163c5ca35 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverter.kt @@ -4,14 +4,21 @@ import com.tangem.datasource.api.tangemTech.models.CardInfoBody import com.tangem.datasource.api.tangemTech.models.WalletIdBody import com.tangem.datasource.api.tangemTech.models.WalletType import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.models.AppsFlyerConversionData object WalletIdBodyConverter { - fun convert(userWallet: UserWallet, publicKeys: Map? = null): WalletIdBody { + fun convert( + userWallet: UserWallet, + conversionData: AppsFlyerConversionData? = null, + publicKeys: Map? = null, + ): WalletIdBody { return WalletIdBody( walletId = userWallet.walletId.stringValue, name = userWallet.name, walletType = WalletType.from(userWallet), + refcode = conversionData?.refcode, + campaign = conversionData?.campaign, cards = publicKeys?.map { publicKeyById -> CardInfoBody( cardId = publicKeyById.key, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/BindWalletsByReferralCodeBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/BindWalletsByReferralCodeBody.kt new file mode 100644 index 0000000000..1eac85742a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/BindWalletsByReferralCodeBody.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class BindWalletsByReferralCodeBody( + @Json(name = "walletIds") val walletIds: List, + @Json(name = "referralCode") val refcode: String, + @Json(name = "utmCampaign") val campaign: String? = null, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt index c9602c0247..c0edc1becf 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt @@ -17,6 +17,8 @@ data class UserTokensResponse( @Json(name = "notifyStatus") val notifyStatus: Boolean? = null, @Json(name = "name") val walletName: String? = null, @Json(name = "type") val walletType: WalletType? = null, + @Json(name = "ref") val refcode: String? = null, + @Json(name = "campaign") val campaign: String? = null, ) { @JsonClass(generateAdapter = true) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt index cb4bd5f2df..597380b6f7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt @@ -9,4 +9,6 @@ data class WalletIdBody( @Json(name = "name") val name: String, @Json(name = "type") val walletType: WalletType? = null, @Json(name = "cards") val cards: List? = null, + @Json(name = "ref") val refcode: String? = null, + @Json(name = "campaign") val campaign: String? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AppsFlyerConversionStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AppsFlyerConversionStoreModule.kt new file mode 100644 index 0000000000..d9222d32e3 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AppsFlyerConversionStoreModule.kt @@ -0,0 +1,21 @@ +package com.tangem.datasource.di + +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore +import com.tangem.datasource.local.appsflyer.DefaultAppsFlyerConversionStore +import com.tangem.datasource.local.preferences.AppPreferencesStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object AppsFlyerConversionStoreModule { + + @Provides + @Singleton + fun provideAppFlyerConversionStore(appPreferencesStore: AppPreferencesStore): AppsFlyerConversionStore { + return DefaultAppsFlyerConversionStore(appPreferencesStore = appPreferencesStore) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerConversionStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerConversionStore.kt new file mode 100644 index 0000000000..300c4c6f20 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/AppsFlyerConversionStore.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.local.appsflyer + +import com.tangem.domain.wallets.models.AppsFlyerConversionData + +interface AppsFlyerConversionStore { + + suspend fun get(): AppsFlyerConversionData? + + suspend fun store(value: AppsFlyerConversionData) + + suspend fun storeIfAbsent(value: AppsFlyerConversionData) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/ConversionDataConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/ConversionDataConverter.kt new file mode 100644 index 0000000000..4afca6f7b6 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/ConversionDataConverter.kt @@ -0,0 +1,21 @@ +package com.tangem.datasource.local.appsflyer + +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.utils.converter.TwoWayConverter + +internal object ConversionDataConverter : TwoWayConverter { + + override fun convert(value: AppsFlyerConversionData): ConversionDataDTO { + return ConversionDataDTO( + refcode = value.refcode, + campaign = value.campaign, + ) + } + + override fun convertBack(value: ConversionDataDTO): AppsFlyerConversionData { + return AppsFlyerConversionData( + refcode = value.refcode, + campaign = value.campaign, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerConversionStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerConversionStore.kt new file mode 100644 index 0000000000..c6a8044516 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/appsflyer/DefaultAppsFlyerConversionStore.kt @@ -0,0 +1,53 @@ +package com.tangem.datasource.local.appsflyer + +import androidx.datastore.preferences.core.stringPreferencesKey +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull +import com.tangem.datasource.local.preferences.utils.storeObject +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import timber.log.Timber + +internal class DefaultAppsFlyerConversionStore( + private val appPreferencesStore: AppPreferencesStore, +) : AppsFlyerConversionStore { + + override suspend fun get(): AppsFlyerConversionData? { + val dto = appPreferencesStore.getObjectSyncOrNull(KEY) ?: return null + + return ConversionDataConverter.convertBack(value = dto).also { + Timber.i("Getting conversion data from store: $it") + } + } + + override suspend fun store(value: AppsFlyerConversionData) { + Timber.i("Storing conversion data to store: $value") + + val dto = ConversionDataConverter.convert(value) + + appPreferencesStore.storeObject(KEY, dto) + } + + override suspend fun storeIfAbsent(value: AppsFlyerConversionData) { + Timber.i("Storing conversion data to store if absent: $value") + + val dto = appPreferencesStore.getObjectSyncOrNull(KEY) + + if (dto == null) { + Timber.i("Conversion data is absent, storing $value") + appPreferencesStore.storeObject(KEY, ConversionDataConverter.convert(value)) + } + } + + private companion object { + + val KEY = stringPreferencesKey("APPS_FLYER_CONVERSION_DATA") + } +} + +@JsonClass(generateAdapter = true) +internal data class ConversionDataDTO( + @Json(name = "refcode") val refcode: String, + @Json(name = "campaign") val campaign: String?, +) \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverterTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverterTest.kt index 299deb3a22..3ac09a3455 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverterTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/tangemTech/converters/WalletIdBodyConverterTest.kt @@ -30,7 +30,11 @@ class WalletIdBodyConverterTest { ) // WHEN - val result = WalletIdBodyConverter.convert(userWallet, publicKeys) + val result = WalletIdBodyConverter.convert( + userWallet = userWallet, + conversionData = null, + publicKeys = publicKeys, + ) // THEN assertThat(result).isEqualTo( @@ -68,7 +72,11 @@ class WalletIdBodyConverterTest { val publicKeys = emptyMap() // WHEN - val result = WalletIdBodyConverter.convert(userWallet, publicKeys) + val result = WalletIdBodyConverter.convert( + userWallet = userWallet, + conversionData = null, + publicKeys = publicKeys, + ) // THEN assertThat(result).isEqualTo( diff --git a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt index 772a1dce68..266d845b10 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandler.kt @@ -4,22 +4,18 @@ import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher.FetchResult import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code import com.tangem.datasource.api.common.response.ETAG_HEADER import com.tangem.datasource.api.common.response.isNetworkError -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore -import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -27,26 +23,21 @@ import javax.inject.Singleton /** * Handles errors that occur during the fetching of wallet accounts * - * @property tangemTechApi API for network requests - * @property userWalletsStore provides access to user wallets storage * @property userTokensSaver saves user tokens to the storage * @property userTokensResponseStore provides access to user token responses. * @property defaultWalletAccountsResponseFactory creates [GetWalletAccountsResponse] from [UserTokensResponse] - * @property dispatchers dispatchers + * @property walletServerBinder binds user wallets on the server * * @see DefaultWalletAccountsFetcher * [REDACTED_AUTHOR] */ -@Suppress("LongParameterList") @Singleton internal class FetchWalletAccountsErrorHandler @Inject constructor( - private val tangemTechApi: TangemTechApi, - private val userWalletsStore: UserWalletsStore, private val userTokensSaver: UserTokensSaver, private val userTokensResponseStore: UserTokensResponseStore, private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory, - private val dispatchers: CoroutineDispatcherProvider, + private val walletServerBinder: WalletServerBinder, ) { /** @@ -119,13 +110,7 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor( */ private suspend fun createWallet(userWalletId: UserWalletId): String? { - val userWallet = userWalletsStore.getSyncOrNull(key = userWalletId) ?: return null - - val creationResponse = withContext(dispatchers.io) { - tangemTechApi.createWallet( - body = WalletIdBodyConverter.convert(userWallet), - ) - } + val creationResponse = walletServerBinder.bind(userWalletId) return if (creationResponse is ApiResponse.Success && creationResponse.code == Code.CREATED) { creationResponse.headers[ETAG_HEADER]?.firstOrNull() diff --git a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt index 51d9c2ad3f..80d6803bf8 100644 --- a/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/fetcher/FetchWalletAccountsErrorHandlerTest.kt @@ -4,23 +4,22 @@ import com.tangem.data.account.converter.createGetWalletAccountsResponse import com.tangem.data.account.converter.createWalletAccountDTO import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code import com.tangem.datasource.api.common.response.ETAG_HEADER import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.datasource.api.tangemTech.models.WalletIdBody -import com.tangem.datasource.api.tangemTech.models.WalletType import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse import com.tangem.datasource.local.token.UserTokensResponseStore -import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.* +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -33,18 +32,16 @@ import org.junit.jupiter.api.TestInstance class FetchWalletAccountsErrorHandlerTest { private val tangemTechApi: TangemTechApi = mockk() - private val userWalletsStore: UserWalletsStore = mockk() + private val walletServerBinder: WalletServerBinder = mockk() private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true) private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true) private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk() private val handler = FetchWalletAccountsErrorHandler( - tangemTechApi = tangemTechApi, - userWalletsStore = userWalletsStore, + walletServerBinder = walletServerBinder, userTokensSaver = userTokensSaver, userTokensResponseStore = userTokensResponseStore, defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory, - dispatchers = TestingCoroutineDispatcherProvider(), ) private val pushWalletAccounts: suspend (List, String) -> GetWalletAccountsResponse = @@ -55,7 +52,7 @@ class FetchWalletAccountsErrorHandlerTest { fun setupEach() { clearMocks( tangemTechApi, - userWalletsStore, + walletServerBinder, userTokensSaver, userTokensResponseStore, defaultWalletAccountsResponseFactory, @@ -112,22 +109,7 @@ class FetchWalletAccountsErrorHandlerTest { code = Code.CREATED, ) - val walletName = "Wallet" - val userWallet = mockk { - every { this@mockk.walletId } returns userWalletId - every { this@mockk.name } returns walletName - } - coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet - - coEvery { - tangemTechApi.createWallet( - WalletIdBody( - walletId = userWalletId.stringValue, - name = walletName, - walletType = WalletType.COLD, - ), - ) - } returns apiResponse + coEvery { walletServerBinder.bind(userWalletId) } returns apiResponse coEvery { pushWalletAccounts(listOf(accountDTO), eTagValue) } returns savedAccountsResponse // Act @@ -142,13 +124,7 @@ class FetchWalletAccountsErrorHandlerTest { // Assert coVerify { userTokensSaver.pushWithRetryer(userWalletId, response = savedAccountsResponse.toUserTokensResponse()) - tangemTechApi.createWallet( - WalletIdBody( - walletId = userWalletId.stringValue, - name = walletName, - walletType = WalletType.COLD, - ), - ) + walletServerBinder.bind(userWalletId) pushWalletAccounts(listOf(accountDTO), eTagValue) storeWalletAccounts(userWalletId, savedAccountsResponse) } diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt index 2b4e65a095..e0ae509ee6 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensSaver.kt @@ -2,13 +2,14 @@ package com.tangem.data.common.currency import com.tangem.data.common.api.safeApiCall import com.tangem.data.common.tokens.UserTokensBackwardCompatibility +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.isNetworkError import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.WalletType +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles @@ -27,6 +28,8 @@ class UserTokensSaver( private val userTokensResponseStore: UserTokensResponseStore, private val dispatchers: CoroutineDispatcherProvider, private val addressesEnricher: UserTokensResponseAddressesEnricher, + private val walletServerBinder: WalletServerBinder, + private val appsFlyerConversionStore: AppsFlyerConversionStore, private val accountsFeatureToggles: AccountsFeatureToggles, private val pushTokensRetryerPool: RetryerPool, ) { @@ -69,9 +72,13 @@ class UserTokensSaver( pushNew(userWallet = userWallet, response = enrichedResponse, onFailSend = onFailSend) } else { + val conversionData = appsFlyerConversionStore.get() + val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher).copy( walletName = userWallet.name, walletType = WalletType.from(userWallet), + refcode = conversionData?.refcode, + campaign = conversionData?.campaign, ) pushLegacy(userWalletId = userWalletId, response = enrichedResponse, onFailSend = onFailSend) @@ -114,7 +121,7 @@ class UserTokensSaver( apiResponse.cause.isNetworkError(ApiResponseError.HttpException.Code.NOT_FOUND) if (isWalletNotFound) { - tangemTechApi.createWallet(body = WalletIdBodyConverter.convert(userWallet)).bind() + walletServerBinder.bind(userWallet).bind() tangemTechApi.saveTokens( userId = userWallet.walletId.stringValue, diff --git a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt index 48751fa834..9cc64b8160 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt @@ -7,7 +7,10 @@ import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.* import com.tangem.data.common.quote.DefaultQuotesFetcher import com.tangem.data.common.quote.QuotesFetcher +import com.tangem.data.common.wallet.DefaultWalletServerBinder +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore @@ -72,6 +75,8 @@ internal object DataCommonModule { userTokensResponseStore: UserTokensResponseStore, dispatchers: CoroutineDispatcherProvider, addressesEnricher: UserTokensResponseAddressesEnricher, + walletServerBinder: WalletServerBinder, + appsFlyerConversionStore: AppsFlyerConversionStore, accountsFeatureToggles: AccountsFeatureToggles, ): UserTokensSaver { return UserTokensSaver( @@ -84,6 +89,8 @@ internal object DataCommonModule { pushTokensRetryerPool = RetryerPool( coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default), ), + walletServerBinder = walletServerBinder, + appsFlyerConversionStore = appsFlyerConversionStore, ) } @@ -98,4 +105,20 @@ internal object DataCommonModule { fun provideETagsStore(appPreferencesStore: AppPreferencesStore): ETagsStore { return DefaultETagsStore(appPreferencesStore = appPreferencesStore) } + + @Provides + @Singleton + fun provideWalletServerBinder( + userWalletsStore: UserWalletsStore, + appsFlyerConversionStore: AppsFlyerConversionStore, + tangemTechApi: TangemTechApi, + dispatchers: CoroutineDispatcherProvider, + ): WalletServerBinder { + return DefaultWalletServerBinder( + userWalletsStore = userWalletsStore, + appsFlyerConversionStore = appsFlyerConversionStore, + tangemTechApi = tangemTechApi, + dispatchers = dispatchers, + ) + } } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinder.kt b/data/common/src/main/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinder.kt new file mode 100644 index 0000000000..376f29fb84 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinder.kt @@ -0,0 +1,35 @@ +package com.tangem.data.common.wallet + +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext + +internal class DefaultWalletServerBinder( + private val userWalletsStore: UserWalletsStore, + private val appsFlyerConversionStore: AppsFlyerConversionStore, + private val tangemTechApi: TangemTechApi, + private val dispatchers: CoroutineDispatcherProvider, +) : WalletServerBinder { + + override suspend fun bind(userWalletId: UserWalletId): ApiResponse? { + val userWallet = userWalletsStore.getSyncOrNull(key = userWalletId) ?: return null + + return bind(userWallet) + } + + override suspend fun bind(userWallet: UserWallet): ApiResponse { + val conversionData = appsFlyerConversionStore.get() + + return withContext(dispatchers.io) { + tangemTechApi.createWallet( + body = WalletIdBodyConverter.convert(userWallet, conversionData), + ) + } + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/wallet/WalletServerBinder.kt b/data/common/src/main/kotlin/com/tangem/data/common/wallet/WalletServerBinder.kt new file mode 100644 index 0000000000..6aeb5b8a1e --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/wallet/WalletServerBinder.kt @@ -0,0 +1,26 @@ +package com.tangem.data.common.wallet + +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId + +/** + + * +[REDACTED_AUTHOR] + */ +interface WalletServerBinder { + + /** + * Binds a wallet to the server using its [userWalletId]. + * Returns an [ApiResponse] indicating the result of the binding operation, + * or null if the binding was not performed. + */ + suspend fun bind(userWalletId: UserWalletId): ApiResponse? + + /** + * Binds a wallet to the server using the provided [userWallet]. + * Returns an [ApiResponse] indicating the result of the binding operation. + */ + suspend fun bind(userWallet: UserWallet): ApiResponse +} \ No newline at end of file diff --git a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt index bc51068bff..64fe198777 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/currency/UserTokensSaverTest.kt @@ -1,10 +1,12 @@ package com.tangem.data.common.currency +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.WalletType +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.datasource.local.token.UserTokensResponseStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles @@ -27,6 +29,8 @@ class UserTokensSaverTest { private val accountsFeatureToggles = mockk { every { this@mockk.isFeatureEnabled } returns true } + private val walletServerBinder: WalletServerBinder = mockk() + private val appsFlyerConversionStore: AppsFlyerConversionStore = mockk() private val userTokensSaver: UserTokensSaver = UserTokensSaver( tangemTechApi = tangemTechApi, @@ -34,13 +38,21 @@ class UserTokensSaverTest { userTokensResponseStore = userTokensResponseStore, dispatchers = TestingCoroutineDispatcherProvider(), addressesEnricher = enricher, + walletServerBinder = walletServerBinder, + appsFlyerConversionStore = appsFlyerConversionStore, accountsFeatureToggles = accountsFeatureToggles, pushTokensRetryerPool = mockk(), ) @BeforeEach fun resetMocks() { - clearMocks(tangemTechApi, userWalletsStore, userTokensResponseStore, enricher) + clearMocks( + tangemTechApi, + userWalletsStore, + userTokensResponseStore, + enricher, + walletServerBinder, + ) } @Test @@ -109,7 +121,6 @@ class UserTokensSaverTest { coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet coEvery { enricher(userWalletId, response) } returns enrichedResponse coEvery { tangemTechApi.saveTokens(any(), any()) } returns ApiResponse.Error(error) as ApiResponse - coEvery { tangemTechApi.createWallet(body = any()) } returns ApiResponse.Error(error) as ApiResponse // WHEN userTokensSaver.push( diff --git a/data/common/src/test/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinderTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinderTest.kt new file mode 100644 index 0000000000..620f841b4a --- /dev/null +++ b/data/common/src/test/kotlin/com/tangem/data/common/wallet/DefaultWalletServerBinderTest.kt @@ -0,0 +1,226 @@ +package com.tangem.data.common.wallet + +import com.google.common.truth.Truth +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.WalletIdBody +import com.tangem.datasource.api.tangemTech.models.WalletType +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultWalletServerBinderTest { + + private val userWalletsStore: UserWalletsStore = mockk() + private val appsFlyerConversionStore: AppsFlyerConversionStore = mockk() + private val tangemTechApi: TangemTechApi = mockk() + private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider() + + private val binder = DefaultWalletServerBinder( + userWalletsStore = userWalletsStore, + appsFlyerConversionStore = appsFlyerConversionStore, + tangemTechApi = tangemTechApi, + dispatchers = dispatchers + ) + + private val userWalletId = UserWalletId("011") + private val userWallet = mockk { + every { this@mockk.walletId } returns userWalletId + every { this@mockk.name } returns "Wallet" + } + private val conversionData = AppsFlyerConversionData(refcode = "refcode", campaign = "campaign") + + @AfterEach + fun tearDown() { + clearMocks(userWalletsStore, appsFlyerConversionStore, tangemTechApi) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class BindUserWalletId { + + @Test + fun `bind successfully`() = runTest { + val requestBody = WalletIdBody( + walletId = userWalletId.stringValue, + name = "Wallet", + walletType = WalletType.COLD, + refcode = "refcode", + campaign = "campaign", + ) + val apiResponse = ApiResponse.Success(Unit) + + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet + coEvery { appsFlyerConversionStore.get() } returns conversionData + coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse + + val actual = binder.bind(userWalletId = userWalletId) + + Truth.assertThat(actual).isEqualTo(apiResponse) + + coVerifyOrder { + userWalletsStore.getSyncOrNull(userWalletId) + appsFlyerConversionStore.get() + tangemTechApi.createWallet(requestBody) + } + } + + @Test + fun `bind will skipped if userWalletsStore returns null`() = runTest { + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns null + + val actual = binder.bind(userWalletId = userWalletId) + + Truth.assertThat(actual).isEqualTo(null) + + coVerifyOrder { + userWalletsStore.getSyncOrNull(userWalletId) + } + + coVerify(inverse = true) { + appsFlyerConversionStore.get() + tangemTechApi.createWallet(any()) + } + } + + @Test + fun `bind successfully if appsFlyerConversionStore returns null`() = runTest { + val requestBody = WalletIdBody( + walletId = userWalletId.stringValue, + name = "Wallet", + walletType = WalletType.COLD, + ) + val apiResponse = ApiResponse.Success(Unit) + + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet + coEvery { appsFlyerConversionStore.get() } returns null + coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse + + val actual = binder.bind(userWalletId = userWalletId) + + Truth.assertThat(actual).isEqualTo(apiResponse) + + coVerifyOrder { + userWalletsStore.getSyncOrNull(userWalletId) + appsFlyerConversionStore.get() + tangemTechApi.createWallet(requestBody) + } + } + + @Test + fun `bind returns error if request is failed`() = runTest { + val requestBody = WalletIdBody( + walletId = userWalletId.stringValue, + name = "Wallet", + walletType = WalletType.COLD, + refcode = "refcode", + campaign = "campaign", + ) + val apiResponse = ApiResponse.Error(ApiResponseError.TimeoutException()) as ApiResponse + + coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet + coEvery { appsFlyerConversionStore.get() } returns conversionData + coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse + + val actual = binder.bind(userWalletId = userWalletId) + + Truth.assertThat(actual).isEqualTo(apiResponse) + + coVerifyOrder { + userWalletsStore.getSyncOrNull(userWalletId) + appsFlyerConversionStore.get() + tangemTechApi.createWallet(requestBody) + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class BindUserWallet { + + @Test + fun `bind successfully`() = runTest { + val requestBody = WalletIdBody( + walletId = userWalletId.stringValue, + name = "Wallet", + walletType = WalletType.COLD, + refcode = "refcode", + campaign = "campaign", + ) + val apiResponse = ApiResponse.Success(Unit) + + coEvery { appsFlyerConversionStore.get() } returns conversionData + coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse + + val actual = binder.bind(userWallet = userWallet) + + Truth.assertThat(actual).isEqualTo(apiResponse) + + coVerifyOrder { + appsFlyerConversionStore.get() + tangemTechApi.createWallet(requestBody) + } + } + + @Test + fun `bind successfully if appsFlyerConversionStore returns null`() = runTest { + val requestBody = WalletIdBody( + walletId = userWalletId.stringValue, + name = "Wallet", + walletType = WalletType.COLD, + ) + val apiResponse = ApiResponse.Success(Unit) + + coEvery { appsFlyerConversionStore.get() } returns null + coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse + + val actual = binder.bind(userWallet = userWallet) + + Truth.assertThat(actual).isEqualTo(apiResponse) + + coVerifyOrder { + appsFlyerConversionStore.get() + tangemTechApi.createWallet(requestBody) + } + } + + @Test + fun `bind returns error if request is failed`() = runTest { + val requestBody = WalletIdBody( + walletId = userWalletId.stringValue, + name = "Wallet", + walletType = WalletType.COLD, + refcode = "refcode", + campaign = "campaign", + ) + val apiResponse = ApiResponse.Error(ApiResponseError.TimeoutException()) as ApiResponse + + coEvery { appsFlyerConversionStore.get() } returns conversionData + coEvery { tangemTechApi.createWallet(requestBody) } returns apiResponse + + val actual = binder.bind(userWallet = userWallet) + + Truth.assertThat(actual).isEqualTo(apiResponse) + + coVerifyOrder { + appsFlyerConversionStore.get() + tangemTechApi.createWallet(requestBody) + } + } + } +} \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsPromoRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsPromoRepository.kt new file mode 100644 index 0000000000..de2bbc191e --- /dev/null +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsPromoRepository.kt @@ -0,0 +1,87 @@ +package com.tangem.data.wallets + +import androidx.datastore.preferences.core.stringPreferencesKey +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.BindWalletsByReferralCodeBody +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull +import com.tangem.datasource.local.preferences.utils.storeObject +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.domain.wallets.repository.WalletsPromoRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import timber.log.Timber + +internal class DefaultWalletsPromoRepository( + private val appPreferencesStore: AppPreferencesStore, + private val tangemTechApi: TangemTechApi, + private val userWalletsStore: UserWalletsStore, + private val dispatchers: CoroutineDispatcherProvider, +) : WalletsPromoRepository { + + override suspend fun bindRefcodeWithWallets(conversionData: AppsFlyerConversionData) = withContext(dispatchers.io) { + val savedBindingData = getSavedBindingData() + + when { + savedBindingData == null -> { + // first time binding + saveBindingData(refcode = conversionData.refcode, campaign = conversionData.campaign) + bind(refcode = conversionData.refcode, campaign = conversionData.campaign) + } + !savedBindingData.isDone -> { + // try again + bind(refcode = savedBindingData.refcode, campaign = savedBindingData.campaign) + } + } + } + + override suspend fun retryBindRefcodeWithWallets() { + val savedBindingData = getSavedBindingData() + + if (savedBindingData != null) { + bind(refcode = savedBindingData.refcode, campaign = savedBindingData.campaign) + } else { + Timber.i("retryBindRefcodeWithWallets: Binding data isn't required") + } + } + + private suspend fun getSavedBindingData(): ReferralWalletsBindingData? { + return appPreferencesStore.getObjectSyncOrNull(key = REFERRAL_WALLETS_BINDING_DATA_KEY) + } + + private suspend fun saveBindingData(refcode: String, campaign: String?) { + val bindingData = ReferralWalletsBindingData(refcode = refcode, campaign = campaign, isDone = false) + + appPreferencesStore.storeObject(key = REFERRAL_WALLETS_BINDING_DATA_KEY, value = bindingData) + } + + private suspend fun bind(refcode: String, campaign: String?) { + val walletIds = userWalletsStore.userWalletsSync.map { it.walletId.stringValue } + + val result = tangemTechApi.bindWalletsByReferralCode( + body = BindWalletsByReferralCodeBody(walletIds = walletIds, refcode = refcode, campaign = campaign), + ) + + if (result is ApiResponse.Success) { + val successData = ReferralWalletsBindingData(refcode = refcode, campaign = campaign, isDone = true) + + appPreferencesStore.storeObject(key = REFERRAL_WALLETS_BINDING_DATA_KEY, value = successData) + } + } + + @JsonClass(generateAdapter = true) + private data class ReferralWalletsBindingData( + @Json(name = "refcode") val refcode: String, + @Json(name = "campaign") val campaign: String?, + @Json(name = "done") val isDone: Boolean, + ) + + private companion object { + + val REFERRAL_WALLETS_BINDING_DATA_KEY by lazy { stringPreferencesKey(name = "referralWalletsBindingData") } + } +} \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt index def9a96402..ab7d7a4c66 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt @@ -3,6 +3,7 @@ package com.tangem.data.wallets import arrow.core.Either import arrow.core.left import arrow.core.right +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.data.wallets.converters.UserWalletRemoteInfoConverter import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.response.ApiResponse @@ -14,6 +15,7 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter import com.tangem.datasource.api.tangemTech.models.* import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO.Status +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys @@ -46,6 +48,8 @@ internal class DefaultWalletsRepository( private val seedPhraseNotificationVisibilityStore: RuntimeStateStore, private val dispatchers: CoroutineDispatcherProvider, private val authProvider: AuthProvider, + private val walletServerBinder: WalletServerBinder, + private val appsFlyerConversionStore: AppsFlyerConversionStore, private val accountsFeatureToggles: AccountsFeatureToggles, private val moshi: com.squareup.moshi.Moshi, ) : WalletsRepository { @@ -250,13 +254,7 @@ internal class DefaultWalletsRepository( } override suspend fun createWallet(userWalletId: UserWalletId) { - runCatching(dispatchers.io) { - val userWallet = userWalletsStore.getSyncStrict(key = userWalletId) - - tangemTechApi.createWallet( - body = WalletIdBodyConverter.convert(userWallet = userWallet), - ) - } + walletServerBinder.bind(userWalletId) } override suspend fun rejectSeedPhraseSecondNotification(userWalletId: UserWalletId) { @@ -413,10 +411,12 @@ internal class DefaultWalletsRepository( associateApplicationIdWithWallets().getOrThrow() } } else { + val conversionData = appsFlyerConversionStore.get() val publicKeys = authProvider.getCardsPublicKeys() val walletsBody = wallets.map { userWallet -> WalletIdBodyConverter.convert( userWallet = userWallet, + conversionData = conversionData, publicKeys = if (userWallet is UserWallet.Cold) { publicKeys.filterKeys { userWallet.cardsInWallet.contains(it) diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt b/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt index a1e870a3e1..b36b580da9 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt @@ -1,7 +1,9 @@ package com.tangem.data.wallets.di import com.squareup.moshi.Moshi +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.data.wallets.DefaultWalletNamesMigrationRepository +import com.tangem.data.wallets.DefaultWalletsPromoRepository import com.tangem.data.wallets.DefaultWalletsRepository import com.tangem.data.wallets.cold.DefaultColdMapDerivationsRepository import com.tangem.data.wallets.derivations.DefaultDerivationsRepository @@ -10,6 +12,7 @@ import com.tangem.data.wallets.hot.DefaultHotWalletAccessCodeAttemptsRepository import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.userwallet.UserWalletsStore @@ -19,6 +22,7 @@ import com.tangem.domain.wallets.derivations.DerivationsRepository import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.repository.WalletNamesMigrationRepository +import com.tangem.domain.wallets.repository.WalletsPromoRepository import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Binds @@ -40,6 +44,8 @@ internal object WalletsDataModule { userWalletsStore: UserWalletsStore, dispatchers: CoroutineDispatcherProvider, authProvider: AuthProvider, + walletServerBinder: WalletServerBinder, + appsFlyerConversionStore: AppsFlyerConversionStore, accountsFeatureToggles: AccountsFeatureToggles, @NetworkMoshi moshi: Moshi, ): WalletsRepository { @@ -50,6 +56,8 @@ internal object WalletsDataModule { seedPhraseNotificationVisibilityStore = RuntimeStateStore(defaultValue = emptyMap()), dispatchers = dispatchers, authProvider = authProvider, + walletServerBinder = walletServerBinder, + appsFlyerConversionStore = appsFlyerConversionStore, accountsFeatureToggles = accountsFeatureToggles, moshi = moshi, ) @@ -60,6 +68,22 @@ internal object WalletsDataModule { fun provideMigrateNamesRepository(appPreferencesStore: AppPreferencesStore): WalletNamesMigrationRepository { return DefaultWalletNamesMigrationRepository(appPreferencesStore) } + + @Provides + @Singleton + fun provideWalletsPromoRepository( + appPreferencesStore: AppPreferencesStore, + tangemTechApi: TangemTechApi, + userWalletsStore: UserWalletsStore, + dispatchers: CoroutineDispatcherProvider, + ): WalletsPromoRepository { + return DefaultWalletsPromoRepository( + appPreferencesStore = appPreferencesStore, + tangemTechApi = tangemTechApi, + userWalletsStore = userWalletsStore, + dispatchers = dispatchers, + ) + } } @Module diff --git a/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt b/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt index 80b3e8d659..22bbfbdf5c 100644 --- a/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt +++ b/data/wallets/src/test/java/com/tangem/data/wallets/DefaultWalletsRepositoryTest.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import com.google.common.truth.Truth.assertThat import com.squareup.moshi.Moshi +import com.tangem.data.common.wallet.WalletServerBinder import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError.HttpException @@ -11,6 +12,7 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.PromocodeActivationBody import com.tangem.datasource.api.tangemTech.models.PromocodeActivationResponse import com.tangem.datasource.api.tangemTech.models.WalletResponse +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles @@ -38,12 +40,16 @@ class DefaultWalletsRepositoryTest { ) private lateinit var tangemTechApi: TangemTechApi private lateinit var dispatchers: CoroutineDispatcherProvider + private lateinit var walletServerBinder: WalletServerBinder + private lateinit var appsFlyerConversionStore: AppsFlyerConversionStore private val testWalletId = UserWalletId("1234567890abcdef") @Before fun setup() { tangemTechApi = mockk() + walletServerBinder = mockk() + appsFlyerConversionStore = mockk() dispatchers = TestingCoroutineDispatcherProvider() repository = DefaultWalletsRepository( appPreferencesStore = appPreferenceStore, @@ -52,6 +58,8 @@ class DefaultWalletsRepositoryTest { seedPhraseNotificationVisibilityStore = mockk(), dispatchers = dispatchers, authProvider = mockk(), + walletServerBinder = walletServerBinder, + appsFlyerConversionStore = appsFlyerConversionStore, accountsFeatureToggles = mockk(), moshi = mockk(), ) @@ -218,10 +226,14 @@ class DefaultWalletsRepositoryTest { seedPhraseNotificationVisibilityStore = mockk(), dispatchers = dispatchers, authProvider = authProvider, + walletServerBinder = walletServerBinder, + appsFlyerConversionStore = appsFlyerConversionStore, accountsFeatureToggles = accountsFeatureToggles, moshi = mockk(), ) + coEvery { appsFlyerConversionStore.get() } returns null + coEvery { tangemTechApi.associateApplicationIdWithWallets( eq(applicationId), diff --git a/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/AppsFlyerConversionData.kt b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/AppsFlyerConversionData.kt new file mode 100644 index 0000000000..4a8055e472 --- /dev/null +++ b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/AppsFlyerConversionData.kt @@ -0,0 +1,3 @@ +package com.tangem.domain.wallets.models + +data class AppsFlyerConversionData(val refcode: String, val campaign: String?) \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsPromoRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsPromoRepository.kt new file mode 100644 index 0000000000..d14afae786 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsPromoRepository.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.wallets.repository + +import com.tangem.domain.wallets.models.AppsFlyerConversionData + +interface WalletsPromoRepository { + + suspend fun bindRefcodeWithWallets(conversionData: AppsFlyerConversionData) + + suspend fun retryBindRefcodeWithWallets() +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/BindRefcodeWithWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/BindRefcodeWithWalletUseCase.kt new file mode 100644 index 0000000000..50ea60f794 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/BindRefcodeWithWalletUseCase.kt @@ -0,0 +1,35 @@ +package com.tangem.domain.wallets.usecase + +import arrow.core.Either +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.domain.wallets.repository.WalletsPromoRepository +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject + +class BindRefcodeWithWalletUseCase @Inject constructor( + private val walletsPromoRepository: WalletsPromoRepository, +) { + + private val mutex = Mutex() + + suspend operator fun invoke(conversionData: AppsFlyerConversionData): Either = either { + mutex.withLock { + catch( + block = { walletsPromoRepository.bindRefcodeWithWallets(conversionData) }, + catch = ::raise, + ) + } + } + + suspend fun retry(): Either = either { + mutex.withLock { + catch( + block = { walletsPromoRepository.retryBindRefcodeWithWallets() }, + catch = ::raise, + ) + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 2e9816c24a..8618fdb312 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -103,6 +103,7 @@ internal class WalletModel @Inject constructor( private val singleAccountListSupplier: SingleAccountListSupplier, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val feedFeatureToggle: FeedFeatureToggle, + private val bindRefcodeWithWalletUseCase: BindRefcodeWithWalletUseCase, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -137,6 +138,11 @@ internal class WalletModel @Inject constructor( enableNotificationsIfNeeded() clickIntents.initialize(innerWalletRouter, modelScope) + + modelScope.launch { + bindRefcodeWithWalletUseCase.retry() + .onLeft { Timber.e("Failed to bind refcode with wallets: $it") } + } } fun onResume() { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt index 5c151cb3ef..1592886d35 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/deeplink/DefaultPromoDeeplinkHandler.kt @@ -1,6 +1,7 @@ package com.tangem.feature.wallet.deeplink import com.tangem.blockchain.common.Blockchain +import com.tangem.common.routing.deeplink.DeeplinkConst import com.tangem.common.routing.deeplink.DeeplinkConst.PROMO_CODE_KEY import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.GlobalUiMessageSender @@ -9,6 +10,7 @@ import com.tangem.core.ui.extensions.mask import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.GlobalLoadingMessage +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.domain.models.network.NetworkStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.networks.multi.MultiNetworkStatusProducer @@ -17,8 +19,10 @@ import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.wallets.PromoCodeActivationResult import com.tangem.domain.wallets.PromoCodeActivationResult.* +import com.tangem.domain.wallets.models.AppsFlyerConversionData import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import com.tangem.domain.wallets.usecase.ActivateBitcoinPromocodeUseCase +import com.tangem.domain.wallets.usecase.BindRefcodeWithWalletUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.feature.wallet.deeplink.analytics.PromoActivationAnalytics import com.tangem.feature.wallet.impl.R @@ -44,10 +48,15 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val activateBitcoinPromocodeUseCase: ActivateBitcoinPromocodeUseCase, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val appsFlyerConversionStore: AppsFlyerConversionStore, + private val bindRefcodeWithWalletUseCase: BindRefcodeWithWalletUseCase, private val analyticsEventsHandler: AnalyticsEventHandler, private val dispatchers: CoroutineDispatcherProvider, ) : PromoDeeplinkHandler { + private val refcode: String? = queryParams[DeeplinkConst.REF_KEY] + private val campaign: String? = queryParams[DeeplinkConst.CAMPAIGN_KEY] + init { analyticsEventsHandler.send(PromoActivationAnalytics.PromoDeepLinkActivationStart()) val promoCode = queryParams[PROMO_CODE_KEY].orEmpty() @@ -56,6 +65,8 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( } else { findSelectedWallet(promoCode) } + + saveAndBindRefcode() } private fun findSelectedWallet(promoCode: String) { @@ -86,13 +97,9 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( Timber.tag(LOG_TAG).d("All user network statuses ${networkStatuses?.size}") - val cryptoCurrencies = multiWalletCryptoCurrenciesSupplier - .getSyncOrNull( - MultiWalletCryptoCurrenciesProducer.Params( - userWallet - .walletId, - ), - ) + val cryptoCurrencies = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + MultiWalletCryptoCurrenciesProducer.Params(userWallet.walletId), + ) Timber.tag(LOG_TAG).d("All user cryptoCurrencies on main ${cryptoCurrencies?.size}") @@ -100,8 +107,7 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( Timber.tag(LOG_TAG).d("BitcoinCurrency $bitcoinCurrency") val bitcoinStatus = networkStatuses?.firstOrNull { status -> - status.network.id == bitcoinCurrency - ?.network?.id + status.network.id == bitcoinCurrency?.network?.id } Timber.tag(LOG_TAG).d("BitcoinStatus $bitcoinStatus") @@ -122,6 +128,7 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( Timber.tag(LOG_TAG).d( "Start activation promoCode ${promoCode.mask()} address ${bitcoinAddress.mask()}", ) + activatePromoCode(bitcoinAddress = bitcoinAddress, promoCode = promoCode) } else { uiMessageSender.send(GlobalLoadingMessage(false)) @@ -182,6 +189,19 @@ internal class DefaultPromoDeeplinkHandler @AssistedInject constructor( ) } + private fun saveAndBindRefcode() { + scope.launch(dispatchers.default) { + if (!refcode.isNullOrBlank()) { + val conversionData = AppsFlyerConversionData(refcode = refcode, campaign = campaign) + + appsFlyerConversionStore.storeIfAbsent(value = conversionData) + + bindRefcodeWithWalletUseCase(conversionData) + .onLeft { Timber.e("Failed to bind refcode with wallets: $it") } + } + } + } + @AssistedFactory interface Factory : PromoDeeplinkHandler.Factory { override fun create( diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt index b6844fd970..1c8c5e7577 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt @@ -12,6 +12,7 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage +import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore import com.tangem.domain.models.StatusSource import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -25,6 +26,7 @@ import com.tangem.domain.wallets.PromoCodeActivationResult import com.tangem.domain.wallets.models.GetUserWalletError import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import com.tangem.domain.wallets.usecase.ActivateBitcoinPromocodeUseCase +import com.tangem.domain.wallets.usecase.BindRefcodeWithWalletUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.feature.wallet.deeplink.DefaultPromoDeeplinkHandler import com.tangem.feature.wallet.deeplink.analytics.PromoActivationAnalytics @@ -66,6 +68,12 @@ class DefaultPromoDeeplinkHandlerTest { @MockK private lateinit var analyticsEventHandler: AnalyticsEventHandler + @MockK + private lateinit var bindRefcodeWithWalletUseCase: BindRefcodeWithWalletUseCase + + @MockK + private lateinit var appsFlyerConversionStore: AppsFlyerConversionStore + private lateinit var messages: MutableList @Before @@ -105,6 +113,8 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -137,6 +147,8 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -168,6 +180,8 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -205,6 +219,8 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -246,6 +262,8 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -362,6 +380,8 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -393,6 +413,8 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -433,6 +455,8 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -494,6 +518,8 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -546,6 +572,8 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -604,6 +632,8 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -661,6 +691,8 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -714,6 +746,8 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) @@ -767,6 +801,8 @@ class DefaultPromoDeeplinkHandlerTest { multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, activateBitcoinPromocodeUseCase = activateBitcoinPromocodeUseCase, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + bindRefcodeWithWalletUseCase = bindRefcodeWithWalletUseCase, + appsFlyerConversionStore = appsFlyerConversionStore, analyticsEventsHandler = analyticsEventHandler, dispatchers = dispatcherProvider, ) diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 7e3eb32287..4c87fdffe7 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -5,6 +5,8 @@ androidGradlePlugin = "8.10.1" firebaseCrashlytics = "3.0.1" googleServices = "4.4.1" +playServices = "18.10.0" +playServicesAds = "18.2.0" kotlin = "2.1.10" ksp = "2.1.10-1.0.30" firebasePerf = "1.4.2" @@ -255,6 +257,8 @@ desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desuga googlePlay-review = { module = "com.google.android.play:review", version.ref = "googlePlayReview" } googlePlay-review-ktx = { module = "com.google.android.play:review-ktx", version.ref = "googlePlayReviewKtx" } googlePlay-services-wallet = { module = "com.google.android.gms:play-services-wallet", version.ref = "googlePlayServicesWallet" } +googlePlay-services = { module = "com.google.android.gms:play-services-base", version.ref = "playServices" } +googlePlay-advertising = { module = "com.google.android.gms:play-services-ads-identifier", version.ref = "playServicesAds" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-core = { module = "com.google.dagger:hilt-core", version.ref = "hilt" } hilt-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }