Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-10 16:28:47 +03:00
parent a7d501adc9
commit 9bf5707cac
6 changed files with 62 additions and 36 deletions

View file

@ -27,7 +27,13 @@ internal object ABTestsManagerModule {
return if (BuildConfig.AB_TESTS_ENABLED) { return if (BuildConfig.AB_TESTS_ENABLED) {
AmplitudeABTestsManager( AmplitudeABTestsManager(
application = application, application = application,
apiKey = environmentConfig.amplitudeApiKey, apiKey = if (BuildConfig.TESTER_MENU_ENABLED) {
requireNotNull(environmentConfig.amplitudeApiKeyDev) {
"Amplitude api key not found in ${BuildConfig.BUILD_TYPE}"
}
} else {
environmentConfig.amplitudeApiKey
},
scope = appScope, scope = appScope,
) )
} else { } else {

View file

@ -8,5 +8,5 @@ interface ABTestsManager {
fun removeUserProperties() fun removeUserProperties()
fun getValue(key: String, defaultValue: String): String suspend fun getValue(key: String, defaultValue: String): String
} }

View file

@ -9,7 +9,9 @@ import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
internal class AmplitudeABTestsManager( internal class AmplitudeABTestsManager(
val application: Application, val application: Application,
@ -19,9 +21,13 @@ internal class AmplitudeABTestsManager(
private lateinit var client: ExperimentClient private lateinit var client: ExperimentClient
private val variantsFetched = CompletableDeferred<Unit>()
private val logger = TangemLogger.withTag(TAG)
override fun init() { override fun init() {
if (::client.isInitialized) { if (::client.isInitialized) {
TangemLogger.w("AB Tests manager already initialized, skipping") logger.w("AB Tests manager already initialized, skipping")
return return
} }
@ -40,7 +46,9 @@ internal class AmplitudeABTestsManager(
val allVariants = client.all() val allVariants = client.all()
logAllVariants(allVariants) logAllVariants(allVariants)
} catch (exception: Exception) { } catch (exception: Exception) {
TangemLogger.e("Failed to fetch AB test variants", exception) logger.e("Failed to fetch AB test variants", exception)
} finally {
variantsFetched.complete(Unit)
} }
} }
} }
@ -64,31 +72,45 @@ internal class AmplitudeABTestsManager(
client.setUser(ExperimentUser()) client.setUser(ExperimentUser())
} }
override fun getValue(key: String, defaultValue: String): String { override suspend fun getValue(key: String, defaultValue: String): String {
if (!::client.isInitialized) return defaultValue
awaitVariantsFetched()
return client.variant(key).value ?: defaultValue return client.variant(key).value ?: defaultValue
} }
private fun logAllVariants(allVariants: Map<String, com.amplitude.experiment.Variant>) { private suspend fun awaitVariantsFetched() {
TangemLogger.d("=".repeat(SEPARATOR_LENGTH)) if (variantsFetched.isCompleted) return
TangemLogger.d("AB Tests: Fetched ${allVariants.size} variants") val completed = withTimeoutOrNull(FETCH_AWAIT_TIMEOUT_MILLIS) {
TangemLogger.d("=".repeat(SEPARATOR_LENGTH)) variantsFetched.await()
}
if (completed == null) {
logger.w("AB Tests variants not fetched within $FETCH_AWAIT_TIMEOUT_MILLIS ms, using default value")
// Prevent repeated blocking on subsequent calls; fetch can still complete in background.
variantsFetched.complete(Unit)
}
}
private fun logAllVariants(allVariants: Map<String, com.amplitude.experiment.Variant>) {
val message = buildString {
appendLine("AB Tests: Fetched ${allVariants.size} variants")
if (allVariants.isEmpty()) { if (allVariants.isEmpty()) {
TangemLogger.d("No variants available") append("No variants available")
} else { } else {
allVariants.entries.forEachIndexed { index, (key, variant) -> allVariants.entries.forEachIndexed { index, (key, variant) ->
TangemLogger.d("[${index + 1}/${allVariants.size}] Key: $key") appendLine("[${index + 1}/${allVariants.size}] $key")
TangemLogger.d(" → Value: ${variant.value ?: "null"}") appendLine(" → value: ${variant.value ?: "null"}")
TangemLogger.d(" → Payload: ${variant.payload ?: "null"}") appendLine(" → key: ${variant.key ?: "null"}")
TangemLogger.d(" → Key: ${variant.key ?: "null"}") append(" → payload: ${variant.payload ?: "null"}")
TangemLogger.d("-".repeat(SEPARATOR_LENGTH)) if (index != allVariants.size - 1) appendLine()
}
} }
} }
TangemLogger.d("=".repeat(SEPARATOR_LENGTH)) logger.i(message)
} }
private companion object { private companion object {
const val SEPARATOR_LENGTH = 50 const val TAG = "AmplitudeABTestsManager"
const val FETCH_AWAIT_TIMEOUT_MILLIS = 3_000L
} }
} }

View file

@ -16,7 +16,7 @@ internal class StubABTestsManager : ABTestsManager {
// intentionally do nothing // intentionally do nothing
} }
override fun getValue(key: String, defaultValue: String): String { override suspend fun getValue(key: String, defaultValue: String): String {
return defaultValue return defaultValue
} }
} }

View file

@ -7,9 +7,7 @@ import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.features.swap.SwapFeatureToggles import com.tangem.features.swap.SwapFeatureToggles
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -34,7 +32,7 @@ internal class GetSwapUiModeUseCaseTest {
assertThat(actual).isEqualTo(SwapUIMode.Detailed) assertThat(actual).isEqualTo(SwapUIMode.Detailed)
coVerify(exactly = 0) { swapRepository.getStoredSwapUiMode() } coVerify(exactly = 0) { swapRepository.getStoredSwapUiMode() }
verify(exactly = 0) { abTestsManager.getValue(any(), any()) } coVerify(exactly = 0) { abTestsManager.getValue(any(), any()) }
} }
@Test @Test
@ -46,7 +44,7 @@ internal class GetSwapUiModeUseCaseTest {
val actual = sut.invoke() val actual = sut.invoke()
assertThat(actual).isEqualTo(SwapUIMode.Detailed) assertThat(actual).isEqualTo(SwapUIMode.Detailed)
verify(exactly = 0) { abTestsManager.getValue(any(), any()) } coVerify(exactly = 0) { abTestsManager.getValue(any(), any()) }
} }
@Test @Test
@ -58,7 +56,7 @@ internal class GetSwapUiModeUseCaseTest {
val actual = sut.invoke() val actual = sut.invoke()
assertThat(actual).isEqualTo(SwapUIMode.Simple) assertThat(actual).isEqualTo(SwapUIMode.Simple)
verify(exactly = 0) { abTestsManager.getValue(any(), any()) } coVerify(exactly = 0) { abTestsManager.getValue(any(), any()) }
} }
@Test @Test
@ -66,24 +64,24 @@ internal class GetSwapUiModeUseCaseTest {
runTest { runTest {
coEvery { swapFeatureToggles.isSwapAbEnabled } returns true coEvery { swapFeatureToggles.isSwapAbEnabled } returns true
coEvery { swapRepository.getStoredSwapUiMode() } returns null coEvery { swapRepository.getStoredSwapUiMode() } returns null
every { abTestsManager.getValue("swap_form_variant", "detailed") } returns "detailed" coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns "detailed"
val actual = sut.invoke() val actual = sut.invoke()
assertThat(actual).isEqualTo(SwapUIMode.Detailed) assertThat(actual).isEqualTo(SwapUIMode.Detailed)
verify(exactly = 1) { abTestsManager.getValue("swap_form_variant", "detailed") } coVerify(exactly = 1) { abTestsManager.getValue("swap_form_variant", "detailed") }
} }
@Test @Test
fun `GIVEN toggle enabled and repository empty and AB returns simple WHEN invoke THEN returns Simple`() = runTest { fun `GIVEN toggle enabled and repository empty and AB returns simple WHEN invoke THEN returns Simple`() = runTest {
coEvery { swapFeatureToggles.isSwapAbEnabled } returns true coEvery { swapFeatureToggles.isSwapAbEnabled } returns true
coEvery { swapRepository.getStoredSwapUiMode() } returns null coEvery { swapRepository.getStoredSwapUiMode() } returns null
every { abTestsManager.getValue("swap_form_variant", "detailed") } returns "simple" coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns "simple"
val actual = sut.invoke() val actual = sut.invoke()
assertThat(actual).isEqualTo(SwapUIMode.Simple) assertThat(actual).isEqualTo(SwapUIMode.Simple)
verify(exactly = 1) { abTestsManager.getValue("swap_form_variant", "detailed") } coVerify(exactly = 1) { abTestsManager.getValue("swap_form_variant", "detailed") }
} }
@Test @Test
@ -91,7 +89,7 @@ internal class GetSwapUiModeUseCaseTest {
runTest { runTest {
coEvery { swapFeatureToggles.isSwapAbEnabled } returns true coEvery { swapFeatureToggles.isSwapAbEnabled } returns true
coEvery { swapRepository.getStoredSwapUiMode() } returns null coEvery { swapRepository.getStoredSwapUiMode() } returns null
every { abTestsManager.getValue("swap_form_variant", "detailed") } returns "SIMPLE" coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns "SIMPLE"
val actual = sut.invoke() val actual = sut.invoke()
@ -103,7 +101,7 @@ internal class GetSwapUiModeUseCaseTest {
runTest { runTest {
coEvery { swapFeatureToggles.isSwapAbEnabled } returns true coEvery { swapFeatureToggles.isSwapAbEnabled } returns true
coEvery { swapRepository.getStoredSwapUiMode() } returns null coEvery { swapRepository.getStoredSwapUiMode() } returns null
every { abTestsManager.getValue("swap_form_variant", "detailed") } returns "something_else" coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns "something_else"
val actual = sut.invoke() val actual = sut.invoke()
@ -115,7 +113,7 @@ internal class GetSwapUiModeUseCaseTest {
runTest { runTest {
coEvery { swapFeatureToggles.isSwapAbEnabled } returns true coEvery { swapFeatureToggles.isSwapAbEnabled } returns true
coEvery { swapRepository.getStoredSwapUiMode() } returns null coEvery { swapRepository.getStoredSwapUiMode() } returns null
every { abTestsManager.getValue("swap_form_variant", "detailed") } returns "" coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns ""
val actual = sut.invoke() val actual = sut.invoke()

View file

@ -28,7 +28,7 @@ enum class BuildType(
BuildConfigField.LogEnabled(isEnabled = true), BuildConfigField.LogEnabled(isEnabled = true),
BuildConfigField.TesterMenuAvailability(isEnabled = true), BuildConfigField.TesterMenuAvailability(isEnabled = true),
BuildConfigField.MockDataSource(isEnabled = false), BuildConfigField.MockDataSource(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = false), BuildConfigField.ABTestsEnabled(isEnabled = true),
), ),
), ),
@ -74,7 +74,7 @@ enum class BuildType(
BuildConfigField.LogEnabled(isEnabled = true), BuildConfigField.LogEnabled(isEnabled = true),
BuildConfigField.TesterMenuAvailability(isEnabled = true), BuildConfigField.TesterMenuAvailability(isEnabled = true),
BuildConfigField.MockDataSource(isEnabled = false), BuildConfigField.MockDataSource(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = false), BuildConfigField.ABTestsEnabled(isEnabled = true),
), ),
), ),
@ -114,7 +114,7 @@ enum class BuildType(
BuildConfigField.LogEnabled(isEnabled = false), BuildConfigField.LogEnabled(isEnabled = false),
BuildConfigField.TesterMenuAvailability(isEnabled = false), BuildConfigField.TesterMenuAvailability(isEnabled = false),
BuildConfigField.MockDataSource(isEnabled = false), BuildConfigField.MockDataSource(isEnabled = false),
BuildConfigField.ABTestsEnabled(isEnabled = false), BuildConfigField.ABTestsEnabled(isEnabled = true),
), ),
), ),
; ;