Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-29 21:49:35 +03:00
parent bb5d6848d9
commit 0ee2f48898
29 changed files with 660 additions and 82 deletions

View file

@ -0,0 +1,61 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.survey.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/* Project - API */
implementation(projects.features.survey.api)
/* Domain */
implementation(projects.domain.common)
implementation(projects.domain.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
/* Core */
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.configToggles)
implementation(projects.core.datasource)
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.utils)
/* Common */
implementation(projects.common.routing)
/* DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/* Compose */
implementation(deps.compose.runtime)
implementation(deps.compose.ui)
/* Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
/** Tangem libraries */
implementation(tangemDeps.card.core)
implementation(deps.surveysparrow)
/** Tests */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
}

View file

@ -0,0 +1,73 @@
package com.tangem.features.survey.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.survey.SurveyComponent
import com.tangem.features.survey.SurveyLaunchData
import com.tangem.features.survey.SurveySparrowLauncher
import com.tangem.features.survey.impl.service.SurveyCustomParamsBuilder
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class DefaultSurveyComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SurveyComponent.Params,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val customParamsBuilder: SurveyCustomParamsBuilder,
private val surveySparrowLauncher: SurveySparrowLauncher,
@Suppress("UnusedPrivateProperty") // TODO([REDACTED_TASK_KEY]): emit [Survey] analytics events
private val analyticsEventHandler: AnalyticsEventHandler,
) : SurveyComponent, AppComponentContext by appComponentContext {
init {
// componentScope runs on mainImmediate, so presenting the SDK is already on the main thread.
componentScope.launch {
val launchData = buildLaunchData()
if (launchData != null) {
surveySparrowLauncher.present(activity, launchData)
// TODO([REDACTED_TASK_KEY]): analyticsEventHandler.send(SurveyAnalyticsEvent.Shown(...))
}
router.pop()
}
}
private suspend fun buildLaunchData(): SurveyLaunchData? {
return getSelectedWalletSyncUseCase().fold(
ifLeft = { error ->
TangemLogger.e("$TAG: survey skipped, no available wallet ($error)")
null
},
ifRight = { userWallet ->
SurveyLaunchData(
domain = SURVEY_DOMAIN,
token = params.token,
customParams = customParamsBuilder.build(
userWallet = userWallet,
token = params.token,
displayId = params.displayId,
),
)
},
)
}
@Composable
override fun Content(modifier: Modifier) = Unit
@AssistedFactory
interface Factory : SurveyComponent.Factory {
override fun create(context: AppComponentContext, params: SurveyComponent.Params): DefaultSurveyComponent
}
private companion object {
const val TAG = "SurveyComponent"
const val SURVEY_DOMAIN = "tangem.surveysparrow.com"
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.survey.impl
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.survey.SurveyFeatureToggles
import javax.inject.Inject
internal class DefaultSurveyFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : SurveyFeatureToggles {
override val areSurveysEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15482_SURVEYSPARROW_ENABLED)
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.survey.impl
import android.app.Activity
import com.surveysparrow.ss_android_sdk.SsSurvey
import com.surveysparrow.ss_android_sdk.SurveySparrow
import com.tangem.features.survey.SurveyLaunchData
import com.tangem.features.survey.SurveySparrowLauncher
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
internal class DefaultSurveySparrowLauncher @Inject constructor() : SurveySparrowLauncher {
override fun present(activity: Activity, data: SurveyLaunchData) {
if (activity.isFinishing || activity.isDestroyed) {
TangemLogger.e("$TAG: cannot present survey, activity is finishing/destroyed")
return
}
val survey = try {
SsSurvey(data.domain, data.token).apply {
setSurveyType(SurveySparrow.CLASSIC)
data.customParams.forEach { (key, value) -> addCustomParam(key, value) }
}
} catch (e: Exception) {
TangemLogger.e("$TAG: failed to create SurveySparrow survey", e)
return
}
// Result handling (onActivityResult -> [Survey] Completed/Dismissed) is planned in [REDACTED_TASK_KEY]
SurveySparrow(activity, survey).startSurveyForResult(SURVEY_REQUEST_CODE)
TangemLogger.d("$TAG: survey started (requestCode=$SURVEY_REQUEST_CODE)")
}
private companion object {
const val TAG = "SurveySparrowPresenter"
const val SURVEY_REQUEST_CODE = 1001
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.features.survey.impl.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.features.survey.SurveyFeatureToggles
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultSurveyDeepLinkHandler @AssistedInject constructor(
@Assisted private val queryParams: Map<String, String>,
private val surveyFeatureToggles: SurveyFeatureToggles,
private val appRouter: AppRouter,
) : SurveyDeepLinkHandler {
init {
handleDeepLink()
}
private fun handleDeepLink() {
if (!surveyFeatureToggles.areSurveysEnabled) {
TangemLogger.i("$TAG: survey deeplink ignored, feature is disabled")
return
}
val token = queryParams[QUERY_TOKEN]?.takeIf { it.isNotBlank() }
if (token == null) {
TangemLogger.e("$TAG: survey deeplink ignored, missing 'token' query param")
return
}
appRouter.push(AppRoute.Survey(token = token, displayId = queryParams[QUERY_DISPLAY_ID]))
}
@AssistedFactory
interface Factory : SurveyDeepLinkHandler.Factory {
override fun create(queryParams: Map<String, String>): DefaultSurveyDeepLinkHandler
}
private companion object {
const val TAG = "SurveyDeepLink"
const val QUERY_TOKEN = "token"
const val QUERY_DISPLAY_ID = "display_id"
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.survey.impl.di
import com.tangem.features.survey.SurveyComponent
import com.tangem.features.survey.SurveyFeatureToggles
import com.tangem.features.survey.SurveySparrowLauncher
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
import com.tangem.features.survey.impl.DefaultSurveyComponent
import com.tangem.features.survey.impl.DefaultSurveyFeatureToggles
import com.tangem.features.survey.impl.DefaultSurveySparrowLauncher
import com.tangem.features.survey.impl.deeplink.DefaultSurveyDeepLinkHandler
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface SurveyModule {
@Binds
@Singleton
fun bindSurveyFeatureToggles(impl: DefaultSurveyFeatureToggles): SurveyFeatureToggles
@Binds
@Singleton
fun bindSurveySparrowLauncher(impl: DefaultSurveySparrowLauncher): SurveySparrowLauncher
@Binds
@Singleton
fun bindSurveyComponentFactory(impl: DefaultSurveyComponent.Factory): SurveyComponent.Factory
@Binds
@Singleton
fun bindSurveyDeepLinkHandlerFactory(impl: DefaultSurveyDeepLinkHandler.Factory): SurveyDeepLinkHandler.Factory
}

View file

@ -0,0 +1,48 @@
package com.tangem.features.survey.impl.service
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.AppInstanceIdProvider
import com.tangem.datasource.api.tangemTech.models.WalletType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.SupportedLanguages
import com.tangem.utils.info.AppInfoProvider
import javax.inject.Inject
internal class SurveyCustomParamsBuilder @Inject constructor(
private val appInstanceIdProvider: AppInstanceIdProvider,
private val appInfoProvider: AppInfoProvider,
) {
suspend fun build(userWallet: UserWallet, token: String, displayId: String?): Map<String, String> {
return buildMap {
put(KEY_SURVEY_KEY, token)
put(KEY_WALLET_ID, hashWalletId(userWallet))
WalletType.from(userWallet)?.let { put(KEY_WALLET_TYPE, it.name.lowercase()) }
displayId?.takeIf { it.isNotBlank() }?.let { put(KEY_DISPLAY_ID, it) }
appInstanceIdProvider.getAppInstanceId()?.let { put(KEY_DEVICE_ID, it) }
put(KEY_PLATFORM, appInfoProvider.platform.lowercase())
put(KEY_APP_VERSION, appInfoProvider.appVersion)
put(KEY_LANGUAGE, SupportedLanguages.getCurrentSupportedLanguageCode())
}
}
private fun hashWalletId(userWallet: UserWallet): String {
return userWallet.walletId.stringValue
.hexToBytes()
.calculateSha256()
.toHexString()
}
private companion object {
const val KEY_SURVEY_KEY = "survey_key"
const val KEY_WALLET_ID = "wallet_id"
const val KEY_WALLET_TYPE = "wallet_type"
const val KEY_DISPLAY_ID = "display_id"
const val KEY_DEVICE_ID = "device_id"
const val KEY_PLATFORM = "platform"
const val KEY_APP_VERSION = "app_version"
const val KEY_LANGUAGE = "language"
}
}

View file

@ -0,0 +1,81 @@
package com.tangem.features.survey.impl.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.features.survey.SurveyFeatureToggles
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import io.mockk.verify
import com.tangem.utils.logging.TangemLogger
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class DefaultSurveyDeepLinkHandlerTest {
private val featureToggles = mockk<SurveyFeatureToggles>()
private val appRouter = mockk<AppRouter>(relaxed = true)
@BeforeEach
fun setup() {
mockkObject(TangemLogger)
every { TangemLogger.i(any()) } just Runs
every { TangemLogger.e(any()) } just Runs
}
@AfterEach
fun tearDown() {
unmockkObject(TangemLogger)
}
@Test
fun `does not navigate when feature is disabled`() {
every { featureToggles.areSurveysEnabled } returns false
createHandler(mapOf("token" to TOKEN, "display_id" to DISPLAY_ID))
verify(exactly = 0) { appRouter.push(any(), any()) }
}
@Test
fun `does not navigate when token is missing`() {
every { featureToggles.areSurveysEnabled } returns true
createHandler(emptyMap())
verify(exactly = 0) { appRouter.push(any(), any()) }
}
@Test
fun `pushes survey route with token and display id on happy path`() {
every { featureToggles.areSurveysEnabled } returns true
createHandler(mapOf("token" to TOKEN, "display_id" to DISPLAY_ID))
verify { appRouter.push(route = AppRoute.Survey(token = TOKEN, displayId = DISPLAY_ID), onComplete = any()) }
}
@Test
fun `pushes survey route with null display id when absent`() {
every { featureToggles.areSurveysEnabled } returns true
createHandler(mapOf("token" to TOKEN))
verify { appRouter.push(route = AppRoute.Survey(token = TOKEN, displayId = null), onComplete = any()) }
}
private fun createHandler(queryParams: Map<String, String>) = DefaultSurveyDeepLinkHandler(
queryParams = queryParams,
surveyFeatureToggles = featureToggles,
appRouter = appRouter,
)
private companion object {
const val TOKEN = "ntt-84iF22PDajmervYneMW4kv"
const val DISPLAY_ID = "42"
}
}

View file

@ -0,0 +1,104 @@
package com.tangem.features.survey.impl.service
import com.google.common.truth.Truth.assertThat
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.AppInstanceIdProvider
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.info.AppInfoProvider
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.Locale
internal class SurveyCustomParamsBuilderTest {
private val appInstanceIdProvider = mockk<AppInstanceIdProvider>()
private val appInfoProvider = mockk<AppInfoProvider>()
private val builder = SurveyCustomParamsBuilder(
appInstanceIdProvider = appInstanceIdProvider,
appInfoProvider = appInfoProvider,
)
@BeforeEach
fun setup() {
Locale.setDefault(Locale.ENGLISH)
every { appInfoProvider.platform } returns "Android"
every { appInfoProvider.appVersion } returns "5.40"
coEvery { appInstanceIdProvider.getAppInstanceId() } returns "device-123"
}
@Test
fun `builds all params for a cold wallet`() = runTest {
val wallet = coldWallet(WALLET_ID_HEX)
val params = builder.build(userWallet = wallet, token = TOKEN, displayId = "42")
assertThat(params).containsExactlyEntriesIn(
mapOf(
"survey_key" to TOKEN,
"wallet_id" to expectedWalletIdHash(WALLET_ID_HEX),
"wallet_type" to "cold",
"display_id" to "42",
"device_id" to "device-123",
"platform" to "android",
"app_version" to "5.40",
"language" to "en",
),
)
}
@Test
fun `wallet_id hash is uppercase hex`() = runTest {
val params = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = null)
val walletId = params.getValue("wallet_id")
assertThat(walletId).isEqualTo(walletId.uppercase())
assertThat(walletId).matches("[0-9A-F]+")
}
@Test
fun `wallet_type is hot for a hot wallet`() = runTest {
val wallet = mockk<UserWallet.Hot> { every { walletId } returns UserWalletId(WALLET_ID_HEX) }
val params = builder.build(userWallet = wallet, token = TOKEN, displayId = null)
assertThat(params["wallet_type"]).isEqualTo("hot")
}
@Test
fun `device_id is omitted when app instance id is null`() = runTest {
coEvery { appInstanceIdProvider.getAppInstanceId() } returns null
val params = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = "42")
assertThat(params).doesNotContainKey("device_id")
}
@Test
fun `display_id is omitted when null or blank`() = runTest {
val nullCase = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = null)
val blankCase = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = " ")
assertThat(nullCase).doesNotContainKey("display_id")
assertThat(blankCase).doesNotContainKey("display_id")
}
private fun coldWallet(walletIdHex: String): UserWallet.Cold = mockk {
every { walletId } returns UserWalletId(walletIdHex)
}
private fun expectedWalletIdHash(walletIdHex: String): String =
walletIdHex.hexToBytes().calculateSha256().toHexString()
private companion object {
const val TOKEN = "ntt-84iF22PDajmervYneMW4kv"
const val WALLET_ID_HEX = "0123456789ABCDEF"
}
}