Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-12 13:27:24 +02:00
parent 0fe533f267
commit 7a7204c6c4
32 changed files with 386 additions and 120 deletions

1
core/ab-tests/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,31 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
alias(deps.plugins.ksp)
id("configuration")
}
android {
namespace = "com.tangem.core.abtests"
}
dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Other libraries */
implementation(deps.timber)
/** Core modules */
implementation(projects.core.analytics.models)
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.domain.models)
/** Amplitude experiment */
implementation(deps.amplitude.experiment)
}

View file

@ -0,0 +1,40 @@
package com.tangem.core.abtests.di
import android.app.Application
import com.tangem.core.abtests.BuildConfig
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.abtests.manager.impl.AmplitudeABTestsManager
import com.tangem.core.abtests.manager.impl.StubABTestsManager
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object ABTestsManagerModule {
@Provides
@Singleton
fun provideABTestsManager(
application: Application,
environmentConfigStorage: EnvironmentConfigStorage,
dispatchers: CoroutineDispatcherProvider,
): ABTestsManager {
return if (BuildConfig.AB_TESTS_ENABLED) {
StubABTestsManager()
} else {
AmplitudeABTestsManager(
application = application,
apiKeyProvider = Provider { environmentConfigStorage.getConfigSync().amplitudeApiKey },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
}
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.core.abtests.manager
interface ABTestsManager {
fun init()
fun setUserProperties(userId: String?, batch: String?, productType: String?, firmware: String?)
fun removeUserProperties()
fun getValue(key: String, defaultValue: String): String
}

View file

@ -0,0 +1,95 @@
package com.tangem.core.abtests.manager.impl
import android.app.Application
import com.amplitude.experiment.Experiment
import com.amplitude.experiment.ExperimentClient
import com.amplitude.experiment.ExperimentConfig
import com.amplitude.experiment.ExperimentUser
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.utils.Provider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
internal class AmplitudeABTestsManager(
val application: Application,
val apiKeyProvider: Provider<String>,
val scope: CoroutineScope,
) : ABTestsManager {
private lateinit var client: ExperimentClient
override fun init() {
if (::client.isInitialized) {
Timber.w("AB Tests manager already initialized, skipping")
return
}
client = Experiment.initializeWithAmplitudeAnalytics(
application = application,
apiKey = apiKeyProvider(),
config = ExperimentConfig
.builder()
.automaticFetchOnAmplitudeIdentityChange(true)
.build(),
)
scope.launch {
try {
client.fetch().get()
val allVariants = client.all()
logAllVariants(allVariants)
} catch (exception: Exception) {
Timber.e(exception, "Failed to fetch AB test variants")
}
}
}
override fun setUserProperties(userId: String?, batch: String?, productType: String?, firmware: String?) {
val userProperties = mutableMapOf<String, Any>()
batch?.let { userProperties[AnalyticsParam.BATCH] = it }
productType?.let { userProperties[AnalyticsParam.PRODUCT_TYPE] = it }
firmware?.let { userProperties[AnalyticsParam.FIRMWARE] = it }
client.setUser(
ExperimentUser
.builder()
.userId(userId)
.userProperties(userProperties)
.build(),
)
}
override fun removeUserProperties() {
client.setUser(ExperimentUser())
}
override fun getValue(key: String, defaultValue: String): String {
return client.variant(key).value ?: defaultValue
}
private fun logAllVariants(allVariants: Map<String, com.amplitude.experiment.Variant>) {
Timber.d("=".repeat(SEPARATOR_LENGTH))
Timber.d("AB Tests: Fetched ${allVariants.size} variants")
Timber.d("=".repeat(SEPARATOR_LENGTH))
if (allVariants.isEmpty()) {
Timber.d("No variants available")
} else {
allVariants.entries.forEachIndexed { index, (key, variant) ->
Timber.d("[${index + 1}/${allVariants.size}] Key: $key")
Timber.d(" → Value: ${variant.value ?: "null"}")
Timber.d(" → Payload: ${variant.payload}")
Timber.d(" → Key: ${variant.key}")
Timber.d("-".repeat(SEPARATOR_LENGTH))
}
}
Timber.d("=".repeat(SEPARATOR_LENGTH))
}
private companion object {
const val SEPARATOR_LENGTH = 50
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.core.abtests.manager.impl
import com.tangem.core.abtests.manager.ABTestsManager
internal class StubABTestsManager : ABTestsManager {
override fun init() {
// intentionally do nothing
}
override fun setUserProperties(userId: String?, batch: String?, productType: String?, firmware: String?) {
// intentionally do nothing
}
override fun removeUserProperties() {
// intentionally do nothing
}
override fun getValue(key: String, defaultValue: String): String {
return defaultValue
}
}

View file

@ -61,9 +61,9 @@ object Analytics : GlobalAnalyticsEventHandler {
return paramsInterceptors.remove(interceptorId)
}
override fun setUserId(userWalletId: String) {
override fun setUserId(userId: String) {
analyticsScope.launch {
val userIdHash = userWalletId.hexToBytes()
val userIdHash = userId.hexToBytes()
.calculateSha256()
.toHexString()

View file

@ -6,10 +6,12 @@ import com.tangem.domain.models.wallet.UserWallet
/**
[REDACTED_AUTHOR]
*/
interface AnalyticsContextProxy {
interface TrackingContextProxy {
fun setContext(scanResponse: ScanResponse)
fun setContext(userWallet: UserWallet)
fun addContext(userWallet: UserWallet)
fun setHotWalletContext()