Updated on 2026-08-14
This commit is contained in:
parent
58428d773b
commit
d77875be51
15 changed files with 414 additions and 53 deletions
|
|
@ -108,6 +108,9 @@ configurations.androidTestImplementation {
|
||||||
exclude(module = "protobuf-lite")
|
exclude(module = "protobuf-lite")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(projects.domain.legacy)
|
implementation(projects.domain.legacy)
|
||||||
|
|
@ -393,11 +396,10 @@ dependencies {
|
||||||
implementation(files("libs/dexprotector-annotations.jar"))
|
implementation(files("libs/dexprotector-annotations.jar"))
|
||||||
|
|
||||||
/** Testing libraries */
|
/** Testing libraries */
|
||||||
|
testImplementation(projects.test.core)
|
||||||
testImplementation(projects.common.test)
|
testImplementation(projects.common.test)
|
||||||
testImplementation(deps.test.coroutine)
|
|
||||||
testImplementation(deps.test.junit)
|
testImplementation(deps.test.junit)
|
||||||
testImplementation(deps.test.mockk)
|
testRuntimeOnly(deps.test.junit5.engine)
|
||||||
testImplementation(deps.test.truth)
|
|
||||||
androidTestImplementation(deps.test.junit.android)
|
androidTestImplementation(deps.test.junit.android)
|
||||||
androidTestImplementation(deps.test.espresso)
|
androidTestImplementation(deps.test.espresso)
|
||||||
androidTestImplementation(deps.test.espresso.intents)
|
androidTestImplementation(deps.test.espresso.intents)
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,15 @@
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID" />
|
<uses-permission android:name="com.google.android.gms.permission.AD_ID" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" tools:node="remove" />
|
<uses-permission
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" tools:node="remove" />
|
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove" />
|
tools:node="remove" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||||
|
tools:node="remove" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.RECORD_AUDIO"
|
||||||
|
tools:node="remove" />
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.camera" />
|
<uses-feature android:name="android.hardware.camera" />
|
||||||
<uses-feature android:name="android.hardware.camera.autofocus" />
|
<uses-feature android:name="android.hardware.camera.autofocus" />
|
||||||
|
|
@ -36,7 +42,6 @@
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name="com.tangem.tap.TangemHiltApplication"
|
android:name="com.tangem.tap.TangemHiltApplication"
|
||||||
android:theme="@style/AppTheme"
|
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
|
@ -49,6 +54,7 @@
|
||||||
android:resizeableActivity="@bool/resizeable_activity"
|
android:resizeableActivity="@bool/resizeable_activity"
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme"
|
||||||
tools:ignore="GoogleAppIndexingWarning"
|
tools:ignore="GoogleAppIndexingWarning"
|
||||||
tools:replace="android:allowBackup, android:dataExtractionRules, android:fullBackupContent, android:label, android:largeHeap">
|
tools:replace="android:allowBackup, android:dataExtractionRules, android:fullBackupContent, android:label, android:largeHeap">
|
||||||
|
|
||||||
|
|
@ -286,9 +292,20 @@
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data
|
<data
|
||||||
android:scheme="https"
|
|
||||||
android:host="tangem.com"
|
android:host="tangem.com"
|
||||||
android:path="/pay-app" />
|
android:path="/pay-app"
|
||||||
|
android:scheme="https" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data
|
||||||
|
android:host="tangem.onelink.me"
|
||||||
|
android:scheme="https" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
|
@ -326,6 +343,6 @@
|
||||||
</application>
|
</application>
|
||||||
<queries>
|
<queries>
|
||||||
<!-- Needed from Android 11 to open Google Wallet for payment with Visa card -->
|
<!-- Needed from Android 11 to open Google Wallet for payment with Visa card -->
|
||||||
<package android:name="com.google.android.apps.walletnfcrel" />
|
<package android:name="com.google.android.apps.walletnfcrel" />
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import com.tangem.features.hotwallet.HotWalletFeatureToggles
|
||||||
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
|
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
|
||||||
import com.tangem.hot.sdk.TangemHotSdk
|
import com.tangem.hot.sdk.TangemHotSdk
|
||||||
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
|
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.common.log.TangemAppLoggerInitializer
|
||||||
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
|
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
|
||||||
import com.tangem.tap.proxy.AppStateHolder
|
import com.tangem.tap.proxy.AppStateHolder
|
||||||
|
|
@ -157,4 +158,6 @@ interface ApplicationEntryPoint {
|
||||||
fun getTrackingContextProxy(): TrackingContextProxy
|
fun getTrackingContextProxy(): TrackingContextProxy
|
||||||
|
|
||||||
fun getABTestsManager(): ABTestsManager
|
fun getABTestsManager(): ABTestsManager
|
||||||
|
|
||||||
|
fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory
|
||||||
}
|
}
|
||||||
|
|
@ -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.BlockchainExceptionHandler
|
||||||
import com.tangem.tap.common.analytics.handlers.amplitude.AmplitudeAnalyticsHandler
|
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.AppsFlyerAnalyticsHandler
|
||||||
|
import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient
|
||||||
import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandler
|
import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandler
|
||||||
import com.tangem.tap.common.images.createCoilImageLoader
|
import com.tangem.tap.common.images.createCoilImageLoader
|
||||||
import com.tangem.tap.common.log.TangemAppLoggerInitializer
|
import com.tangem.tap.common.log.TangemAppLoggerInitializer
|
||||||
|
|
@ -248,6 +249,9 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
|
||||||
private val abTestsManager: ABTestsManager
|
private val abTestsManager: ABTestsManager
|
||||||
get() = entryPoint.getABTestsManager()
|
get() = entryPoint.getABTestsManager()
|
||||||
|
|
||||||
|
private val appsFlyerClientFactory: AppsFlyerClient.Factory
|
||||||
|
get() = entryPoint.getAppsFlyerClientFactory()
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
private val appScope = MainScope()
|
private val appScope = MainScope()
|
||||||
|
|
@ -421,7 +425,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
|
||||||
val factory = AnalyticsFactory()
|
val factory = AnalyticsFactory()
|
||||||
factory.addHandlerBuilder(AmplitudeAnalyticsHandler.Builder())
|
factory.addHandlerBuilder(AmplitudeAnalyticsHandler.Builder())
|
||||||
factory.addHandlerBuilder(FirebaseAnalyticsHandler.Builder())
|
factory.addHandlerBuilder(FirebaseAnalyticsHandler.Builder())
|
||||||
factory.addHandlerBuilder(AppsFlyerAnalyticsHandler.Builder())
|
factory.addHandlerBuilder(AppsFlyerAnalyticsHandler.Builder(appsFlyerClientFactory))
|
||||||
|
|
||||||
factory.addFilter(oneTimeEventFilter)
|
factory.addFilter(oneTimeEventFilter)
|
||||||
factory.addFilter(AppsFlyerEventFilter())
|
factory.addFilter(AppsFlyerEventFilter())
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
package com.tangem.tap.common.analytics.appsflyer
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import com.appsflyer.deeplink.DeepLink
|
||||||
|
import com.appsflyer.deeplink.DeepLinkListener
|
||||||
|
import com.appsflyer.deeplink.DeepLinkResult
|
||||||
|
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 AppsFlyerDeepLinkListener @Inject constructor(
|
||||||
|
private val appsFlyerConversionStore: AppsFlyerConversionStore,
|
||||||
|
dispatchers: CoroutineDispatcherProvider,
|
||||||
|
) : DeepLinkListener {
|
||||||
|
|
||||||
|
private val coroutineScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
override fun onDeepLinking(p0: DeepLinkResult) {
|
||||||
|
when (p0.status) {
|
||||||
|
DeepLinkResult.Status.FOUND -> {
|
||||||
|
onDeepLinkFound(p0.deepLink)
|
||||||
|
}
|
||||||
|
DeepLinkResult.Status.NOT_FOUND -> {
|
||||||
|
Timber.i("No deep link found")
|
||||||
|
}
|
||||||
|
DeepLinkResult.Status.ERROR -> {
|
||||||
|
Timber.e("Deep link error: ${p0.error}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDeepLinkFound(deepLink: DeepLink) {
|
||||||
|
if (deepLink.deepLinkValue != REFERRAL_DEEP_LINK_VALUE) {
|
||||||
|
Timber.i("Ignoring deep link with value: ${deepLink.deepLinkValue ?: "null"}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val refcode = deepLink.getStringValue(REFCODE_PARAM_KEY)
|
||||||
|
val campaign = deepLink.getStringValue(CAMPAIGN_PARAM_KEY)
|
||||||
|
|
||||||
|
@Suppress("NullableToStringCall")
|
||||||
|
Timber.i("refcode=$refcode\ncampaign=$campaign")
|
||||||
|
|
||||||
|
if (!isValidParam(refcode)) {
|
||||||
|
Timber.e("Deep link conversion data is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storeConversionData(refcode = refcode, campaign = campaign)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.store(
|
||||||
|
value = AppsFlyerConversionData(refcode = refcode, campaign = campaign),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object Companion {
|
||||||
|
|
||||||
|
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
|
||||||
|
const val REFERRAL_DEEP_LINK_VALUE = "referral"
|
||||||
|
|
||||||
|
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
|
||||||
|
const val REFCODE_PARAM_KEY = "deep_link_sub1"
|
||||||
|
|
||||||
|
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
|
||||||
|
const val CAMPAIGN_PARAM_KEY = "deep_link_sub2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,59 +6,36 @@ import com.appsflyer.AppsFlyerLib
|
||||||
import com.appsflyer.attribution.AppsFlyerRequestListener
|
import com.appsflyer.attribution.AppsFlyerRequestListener
|
||||||
import com.tangem.core.analytics.api.EventLogger
|
import com.tangem.core.analytics.api.EventLogger
|
||||||
import com.tangem.core.analytics.api.UserIdHolder
|
import com.tangem.core.analytics.api.UserIdHolder
|
||||||
|
import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeepLinkListener
|
||||||
import com.tangem.tap.common.analytics.handlers.firebase.UnderscoreAnalyticsEventConverter
|
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
|
import timber.log.Timber
|
||||||
|
|
||||||
interface AppsFlyerAnalyticsClient : EventLogger, UserIdHolder
|
interface AppsFlyerAnalyticsClient : EventLogger, UserIdHolder
|
||||||
|
|
||||||
internal class AppsFlyerClient(
|
class AppsFlyerClient @AssistedInject constructor(
|
||||||
private val context: Context,
|
@Assisted apiKey: String,
|
||||||
key: String,
|
@ApplicationContext private val context: Context,
|
||||||
|
appsFlyerDeepLinkListener: AppsFlyerDeepLinkListener,
|
||||||
) : AppsFlyerAnalyticsClient {
|
) : AppsFlyerAnalyticsClient {
|
||||||
|
|
||||||
private val appsFlyerLib: AppsFlyerLib = AppsFlyerLib.getInstance()
|
private val appsFlyerLib: AppsFlyerLib = AppsFlyerLib.getInstance()
|
||||||
private val eventConverter = UnderscoreAnalyticsEventConverter()
|
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 {
|
init {
|
||||||
val conversionListener = object : AppsFlyerConversionListener {
|
with(appsFlyerLib) {
|
||||||
override fun onConversionDataSuccess(p0: Map<String?, Any?>?) {
|
|
||||||
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<String?, String?>?) {
|
|
||||||
Timber.i("AppsFlyer app open attribution: ${p0.orEmpty()}")
|
|
||||||
}
|
|
||||||
override fun onAttributionFailure(p0: String?) {
|
|
||||||
Timber.e("AppsFlyer attribution failure: ${p0.orEmpty()}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
appsFlyerLib.run {
|
|
||||||
setAppId(context.packageName)
|
setAppId(context.packageName)
|
||||||
setDebugLog(true)
|
setDebugLog(true)
|
||||||
|
|
||||||
init(key, conversionListener, context)
|
init(apiKey, ConversionListener, context)
|
||||||
Timber.i("Starting AppsFlyer SDK")
|
|
||||||
start(context, key, object : AppsFlyerRequestListener {
|
|
||||||
override fun onSuccess() {
|
|
||||||
Timber.i("AppsFlyer initialized successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(p0: Int, p1: String) {
|
subscribeForDeepLink(appsFlyerDeepLinkListener)
|
||||||
Timber.e("AppsFlyer initialization error: $p0, $p1")
|
|
||||||
}
|
Timber.i("Starting AppsFlyer SDK")
|
||||||
})
|
start(context, apiKey, InitializationListener)
|
||||||
Timber.i("AppsFlyer SDK started")
|
Timber.i("AppsFlyer SDK started")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +54,47 @@ internal class AppsFlyerClient(
|
||||||
context,
|
context,
|
||||||
event,
|
event,
|
||||||
eventConverter.convertEventParams(params),
|
eventConverter.convertEventParams(params),
|
||||||
logEventListener,
|
LogEventListener,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private object ConversionListener : AppsFlyerConversionListener {
|
||||||
|
override fun onConversionDataSuccess(p0: Map<String?, Any?>?) {
|
||||||
|
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<String?, String?>?) {
|
||||||
|
Timber.i("AppsFlyer app open attribution: ${p0.orEmpty()}")
|
||||||
|
}
|
||||||
|
override fun onAttributionFailure(p0: String?) {
|
||||||
|
Timber.e("AppsFlyer attribution failure: ${p0.orEmpty()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +47,10 @@ class AppsFlyerAnalyticsHandler(
|
||||||
const val ID = "AppsFlyer"
|
const val ID = "AppsFlyer"
|
||||||
}
|
}
|
||||||
|
|
||||||
class Builder : AnalyticsHandlerBuilder {
|
internal class Builder(
|
||||||
|
private val appsFlyerClientFactory: AppsFlyerClient.Factory,
|
||||||
|
) : AnalyticsHandlerBuilder {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
Timber.tag("AppsFlyer").i("AppsFlyer Analytics Handler Builder created")
|
Timber.tag("AppsFlyer").i("AppsFlyer Analytics Handler Builder created")
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +61,7 @@ class AppsFlyerAnalyticsHandler(
|
||||||
AppsFlyerLogClient(data.jsonConverter)
|
AppsFlyerLogClient(data.jsonConverter)
|
||||||
} else {
|
} else {
|
||||||
Timber.tag("AppsFlyer").i("AppsFlyer log disabled, real client created")
|
Timber.tag("AppsFlyer").i("AppsFlyer log disabled, real client created")
|
||||||
AppsFlyerClient(data.application, data.config.appsFlyerApiKey)
|
appsFlyerClientFactory.create(apiKey = data.config.appsFlyerApiKey)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
package com.tangem.tap.common.analytics.appsflyer
|
||||||
|
|
||||||
|
import com.appsflyer.deeplink.DeepLink
|
||||||
|
import com.appsflyer.deeplink.DeepLinkResult
|
||||||
|
import com.tangem.datasource.local.appsflyer.AppsFlyerConversionStore
|
||||||
|
import com.tangem.domain.wallets.models.AppsFlyerConversionData
|
||||||
|
import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeepLinkListener.Companion.CAMPAIGN_PARAM_KEY
|
||||||
|
import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeepLinkListener.Companion.REFCODE_PARAM_KEY
|
||||||
|
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.TestInstance
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
|
||||||
|
/**
|
||||||
|
[REDACTED_AUTHOR]
|
||||||
|
*/
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
class AppsFlyerDeepLinkListenerTest {
|
||||||
|
|
||||||
|
private val appsFlyerConversionStore: AppsFlyerConversionStore = mockk(relaxUnitFun = true)
|
||||||
|
private val listener = AppsFlyerDeepLinkListener(
|
||||||
|
appsFlyerConversionStore = appsFlyerConversionStore,
|
||||||
|
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
clearMocks(appsFlyerConversionStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ProvideTestModels
|
||||||
|
fun onDeepLinking(model: OnDeepLinkingModel) = runTest {
|
||||||
|
listener.onDeepLinking(p0 = model.deepLinkResult)
|
||||||
|
|
||||||
|
if (model.shouldStore) {
|
||||||
|
val value = AppsFlyerConversionData(refcode = SUCCESS_REFCODE, campaign = SUCCESS_CAMPAIGN)
|
||||||
|
coVerify { appsFlyerConversionStore.store(value = value) }
|
||||||
|
} else {
|
||||||
|
coVerify(inverse = true) { appsFlyerConversionStore.store(value = any()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun provideTestModels(): List<OnDeepLinkingModel> {
|
||||||
|
return listOf(
|
||||||
|
// DeepLinkResult.Status.NOT_FOUND
|
||||||
|
OnDeepLinkingModel(
|
||||||
|
deepLinkResult = DeepLinkResult(null, null),
|
||||||
|
shouldStore = false,
|
||||||
|
),
|
||||||
|
// DeepLinkResult.Status.ERROR
|
||||||
|
OnDeepLinkingModel(
|
||||||
|
deepLinkResult = DeepLinkResult(null, DeepLinkResult.Error.NETWORK),
|
||||||
|
shouldStore = false,
|
||||||
|
),
|
||||||
|
// DeepLinkResult.Status.FOUND
|
||||||
|
OnDeepLinkingModel(
|
||||||
|
deepLinkResult = createFoundDeepLink(
|
||||||
|
deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE,
|
||||||
|
refcode = SUCCESS_REFCODE,
|
||||||
|
campaign = SUCCESS_CAMPAIGN,
|
||||||
|
),
|
||||||
|
shouldStore = true,
|
||||||
|
),
|
||||||
|
OnDeepLinkingModel(
|
||||||
|
deepLinkResult = createFoundDeepLink(
|
||||||
|
deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE,
|
||||||
|
refcode = "",
|
||||||
|
campaign = SUCCESS_CAMPAIGN,
|
||||||
|
),
|
||||||
|
shouldStore = false,
|
||||||
|
),
|
||||||
|
OnDeepLinkingModel(
|
||||||
|
deepLinkResult = createFoundDeepLink(
|
||||||
|
deepLinkValue = AppsFlyerDeepLinkListener.REFERRAL_DEEP_LINK_VALUE,
|
||||||
|
refcode = null,
|
||||||
|
campaign = SUCCESS_CAMPAIGN,
|
||||||
|
),
|
||||||
|
shouldStore = false,
|
||||||
|
),
|
||||||
|
OnDeepLinkingModel(
|
||||||
|
deepLinkResult = createFoundDeepLink(deepLinkValue = "some_other_deep_link_value"),
|
||||||
|
shouldStore = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class OnDeepLinkingModel(
|
||||||
|
val deepLinkResult: DeepLinkResult,
|
||||||
|
val shouldStore: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createFoundDeepLink(
|
||||||
|
deepLinkValue: String,
|
||||||
|
refcode: String? = null,
|
||||||
|
campaign: String? = null,
|
||||||
|
): DeepLinkResult {
|
||||||
|
val deeplink = mockk<DeepLink> {
|
||||||
|
every { this@mockk.deepLinkValue } returns deepLinkValue
|
||||||
|
every { this@mockk.getStringValue(REFCODE_PARAM_KEY) } returns refcode
|
||||||
|
every { this@mockk.getStringValue(CAMPAIGN_PARAM_KEY) } returns campaign
|
||||||
|
}
|
||||||
|
|
||||||
|
return mockk<DeepLinkResult> {
|
||||||
|
every { this@mockk.status } returns DeepLinkResult.Status.FOUND
|
||||||
|
every { this@mockk.deepLink } returns deeplink
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SUCCESS_REFCODE = "valid_refcode"
|
||||||
|
const val SUCCESS_CAMPAIGN = "valid_campaign"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,8 @@ object WalletIdBodyConverter {
|
||||||
walletId = userWallet.walletId.stringValue,
|
walletId = userWallet.walletId.stringValue,
|
||||||
name = userWallet.name,
|
name = userWallet.name,
|
||||||
walletType = WalletType.from(userWallet),
|
walletType = WalletType.from(userWallet),
|
||||||
|
refcode = conversionData?.refcode,
|
||||||
|
campaign = conversionData?.campaign,
|
||||||
cards = publicKeys?.map { publicKeyById ->
|
cards = publicKeys?.map { publicKeyById ->
|
||||||
CardInfoBody(
|
CardInfoBody(
|
||||||
cardId = publicKeyById.key,
|
cardId = publicKeyById.key,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.tangem.datasource.local.appsflyer
|
||||||
|
|
||||||
|
import com.tangem.domain.wallets.models.AppsFlyerConversionData
|
||||||
|
|
||||||
|
interface AppsFlyerConversionStore {
|
||||||
|
|
||||||
|
suspend fun get(): AppsFlyerConversionData?
|
||||||
|
|
||||||
|
suspend fun store(value: AppsFlyerConversionData)
|
||||||
|
}
|
||||||
|
|
@ -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<AppsFlyerConversionData, ConversionDataDTO> {
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
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<ConversionDataDTO>(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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?,
|
||||||
|
)
|
||||||
|
|
@ -72,6 +72,8 @@ class UserTokensSaver(
|
||||||
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher).copy(
|
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher).copy(
|
||||||
walletName = userWallet.name,
|
walletName = userWallet.name,
|
||||||
walletType = WalletType.from(userWallet),
|
walletType = WalletType.from(userWallet),
|
||||||
|
refcode = conversionData?.refcode,
|
||||||
|
campaign = conversionData?.campaign,
|
||||||
)
|
)
|
||||||
|
|
||||||
pushLegacy(userWalletId = userWalletId, response = enrichedResponse, onFailSend = onFailSend)
|
pushLegacy(userWalletId = userWalletId, response = enrichedResponse, onFailSend = onFailSend)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package com.tangem.domain.wallets.models
|
||||||
|
|
||||||
|
data class AppsFlyerConversionData(val refcode: String, val campaign: String?)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue