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

@ -283,6 +283,8 @@ dependencies {
implementation(projects.features.onboardingV2.impl)
implementation(projects.features.stories.api)
implementation(projects.features.stories.impl)
implementation(projects.features.survey.api)
implementation(projects.features.survey.impl)
implementation(projects.features.txhistory.api)
implementation(projects.features.txhistory.impl)
implementation(projects.features.biometry.api)

View file

@ -210,6 +210,17 @@
android:scheme="tangem" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="survey"
android:scheme="tangem" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />

View file

@ -6,6 +6,8 @@ import android.net.Uri
import androidx.core.net.toUri
import com.tangem.common.routing.DeepLinkScheme
import com.tangem.common.uri.ExternalUrlValidator
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.utils.logging.TangemLogger
@ -17,6 +19,7 @@ import com.tangem.utils.logging.TangemLogger
internal class DefaultDeeplinkLauncher(
private val context: Context,
private val urlOpener: UrlOpener,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : DeeplinkLauncher {
override fun launch(link: String) {
@ -58,11 +61,33 @@ internal class DefaultDeeplinkLauncher(
}
private fun launchDeepLink(uri: Uri) {
context.startActivity(createDeepLinkIntent(uri))
val intent = createDeepLinkIntent(uri)
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
} else {
TangemLogger.i(
"""
No match found for deep link
|- Received URI: $uri
""".trimIndent(),
)
analyticsExceptionHandler.sendException(
ExceptionAnalyticsEvent(
exception = UnresolvedDeeplinkException(uri),
params = mapOf(
"uri_scheme" to uri.scheme.orEmpty(),
"uri_host" to uri.host.orEmpty(),
),
),
)
}
}
private fun createDeepLinkIntent(uri: Uri): Intent = Intent(Intent.ACTION_VIEW, uri).apply {
setPackage(context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
}
internal class UnresolvedDeeplinkException(uri: Uri) :
RuntimeException("Deeplink has no matching activity: scheme=${uri.scheme}, host=${uri.host}")

View file

@ -1,6 +1,7 @@
package com.tangem.tap.di
import android.content.Context
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.tap.common.deeplink.DefaultDeeplinkLauncher
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.navigation.finisher.AppFinisher
@ -55,7 +56,10 @@ internal interface UtilsModule {
@Provides
@Singleton
fun provideDeeplinkLauncher(@ApplicationContext context: Context, urlOpener: UrlOpener): DeeplinkLauncher =
DefaultDeeplinkLauncher(context, urlOpener)
fun provideDeeplinkLauncher(
@ApplicationContext context: Context,
urlOpener: UrlOpener,
analyticsExceptionHandler: AnalyticsExceptionHandler,
): DeeplinkLauncher = DefaultDeeplinkLauncher(context, urlOpener, analyticsExceptionHandler)
}
}

View file

@ -21,6 +21,7 @@ import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.home.api.HomeComponent
import com.tangem.features.hotwallet.*
import com.tangem.features.kyc.KycComponent
import com.tangem.features.survey.SurveyComponent
import com.tangem.features.managetokens.component.ChooseManagedTokensComponent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensMode
@ -112,6 +113,7 @@ internal class ChildFactory @Inject constructor(
private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory,
private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory,
private val kycComponentFactory: KycComponent.Factory,
private val surveyComponentFactory: SurveyComponent.Factory,
private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory,
private val feedEntryComponentFactory: FeedEntryComponent.Factory,
private val addFundsComponentFactory: AddFundsComponent.Factory,
@ -702,6 +704,13 @@ internal class ChildFactory @Inject constructor(
componentFactory = kycComponentFactory,
)
}
is AppRoute.Survey -> {
createComponentChild(
context = context,
params = SurveyComponent.Params(token = route.token, displayId = route.displayId),
componentFactory = surveyComponentFactory,
)
}
is AppRoute.YieldSupplyEntry -> {
createComponentChild(
context = context,

View file

@ -19,6 +19,7 @@ import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler
import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler
import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler
import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
@ -62,6 +63,7 @@ internal class DeepLinkFactory @Inject constructor(
private val newsDeepLink: NewsDeepLinkHandler.Factory,
private val earnDeepLink: EarnDeepLinkHandler.Factory,
private val yieldDeepLink: YieldDeepLinkHandler.Factory,
private val surveyDeepLink: SurveyDeepLinkHandler.Factory,
) {
private val permittedAppRoute = MutableStateFlow(false)
@ -175,6 +177,7 @@ internal class DeepLinkFactory @Inject constructor(
DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams)
DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.PayAppMain.host -> tangemPayMainDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.Survey.host -> surveyDeepLink.create(queryParams)
else -> {
TangemLogger.i(
"""

View file

@ -11,6 +11,7 @@ import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHand
import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
@ -99,6 +100,10 @@ class DeepLinkFactoryTest {
every { create(any()) } returns mockk()
}
private val surveyDeepLinkFactory = mockk<SurveyDeepLinkHandler.Factory>(relaxed = true) {
every { create(any()) } returns mockk()
}
private val earnDeepLinkFactory = mockk<EarnDeepLinkHandler.Factory>(relaxed = true) {
every { create(any()) } returns mockk()
}
@ -140,6 +145,7 @@ class DeepLinkFactoryTest {
newsDeepLink = newsDeepLinkFactory,
earnDeepLink = earnDeepLinkFactory,
yieldDeepLink = yieldDeepLinkFactory,
surveyDeepLink = surveyDeepLinkFactory,
)
@OptIn(ExperimentalCoroutinesApi::class)

View file

@ -498,6 +498,9 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc")
@Serializable
data class Survey(val token: String, val displayId: String? = null) : AppRoute(path = "/survey")
@Serializable
data class YieldSupplyEntry(
val userWalletId: UserWalletId,

View file

@ -87,6 +87,10 @@ sealed class DeepLinkRoute {
data object PayAppMain : DeepLinkRoute() {
override val host: String = "pay-app-main"
}
data object Survey : DeepLinkRoute() {
override val host: String = "survey"
}
}
enum class DeepLinkScheme(val scheme: String) {

View file

@ -115,6 +115,10 @@
"name": "AND_15438_BACKEND_AUTHENTICATION_ENABLED",
"version": "undefined"
},
{
"name": "AND_15482_SURVEYSPARROW_ENABLED",
"version": "undefined"
},
{
"name": "AND_15258_QUICK_TOP_UP_ENABLED",
"version": "undefined"

View file

@ -1,5 +1,6 @@
package com.tangem.features.promobanners.impl.model
import androidx.core.net.toUri
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
@ -118,7 +119,18 @@ internal class PromoBannersBlockModel @Inject constructor(
private fun onButtonClick(displayId: Int, deeplink: String?) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholderName))
deeplink?.let { deeplinkLauncher.launch(it) }
deeplink?.let { deeplinkLauncher.launch(appendSurveyDisplayId(it, displayId)) }
}
private fun appendSurveyDisplayId(deeplink: String, displayId: Int): String {
val uri = deeplink.toUri()
val isSurveyDeeplink = uri.scheme == DEEPLINK_SCHEME_TANGEM && uri.host == DEEPLINK_HOST_SURVEY
if (!isSurveyDeeplink || uri.getQueryParameter(QUERY_DISPLAY_ID) != null) return deeplink
return uri.buildUpon()
.appendQueryParameter(QUERY_DISPLAY_ID, displayId.toString())
.build()
.toString()
}
private fun getInitialState() = PromoBannersBlockUM(
@ -152,4 +164,10 @@ internal class PromoBannersBlockModel @Inject constructor(
}
}
}
private companion object {
const val DEEPLINK_SCHEME_TANGEM = "tangem"
const val DEEPLINK_HOST_SURVEY = "survey"
const val QUERY_DISPLAY_ID = "display_id"
}
}

View file

@ -0,0 +1,14 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.survey.api"
}
dependencies {
implementation(projects.core.decompose)
implementation(projects.core.ui)
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.survey
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface SurveyComponent : ComposableContentComponent {
data class Params(val token: String, val displayId: String?)
interface Factory : ComponentFactory<Params, SurveyComponent>
}

View file

@ -0,0 +1,6 @@
package com.tangem.features.survey
interface SurveyFeatureToggles {
val areSurveysEnabled: Boolean
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.survey
import android.app.Activity
interface SurveySparrowLauncher {
fun present(activity: Activity, data: SurveyLaunchData)
}
data class SurveyLaunchData(
val domain: String,
val token: String,
val customParams: Map<String, String>,
)

View file

@ -0,0 +1,8 @@
package com.tangem.features.survey.deeplink
interface SurveyDeepLinkHandler {
interface Factory {
fun create(queryParams: Map<String, String>): SurveyDeepLinkHandler
}
}

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"
}
}

View file

@ -47,7 +47,6 @@ dependencies {
/** Other libraries */
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
implementation(deps.surveysparrow)
/** Core modules */
implementation(projects.core.datasource)
@ -60,6 +59,7 @@ dependencies {
/** Feature Apis */
implementation(projects.features.tester.api)
implementation(projects.features.pushNotifications.api)
implementation(projects.features.survey.api)
/* SDK */
implementation(tangemDeps.blockchain)

View file

@ -1,6 +1,5 @@
package com.tangem.feature.tester.presentation
import android.widget.Toast
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -18,7 +17,6 @@ import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeActivity
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.feature.tester.presentation.accounts.ui.AccountsScreen
import com.tangem.feature.tester.presentation.accounts.viewmodel.TesterAccountsViewModel
import com.tangem.feature.tester.presentation.actions.TesterActionsScreen
@ -40,9 +38,10 @@ import com.tangem.feature.tester.presentation.providers.ui.BlockchainProvidersSc
import com.tangem.feature.tester.presentation.providers.viewmodel.BlockchainProvidersViewModel
import com.tangem.feature.tester.presentation.storybook.ui.StoryBookScreen
import com.tangem.feature.tester.presentation.storybook.viewmodel.StoryBookViewModel
import com.tangem.feature.tester.presentation.surveysparrow.SurveySparrowManager
import com.tangem.feature.tester.presentation.testpush.ui.TestPushScreen
import com.tangem.feature.tester.presentation.testpush.viewmodel.TestPushViewModel
import com.tangem.features.survey.SurveyLaunchData
import com.tangem.features.survey.SurveySparrowLauncher
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.collections.immutable.persistentSetOf
import javax.inject.Inject
@ -64,7 +63,7 @@ internal class TesterActivity : ComposeActivity() {
lateinit var appRouter: AppRouter
@Inject
lateinit var environmentConfig: EnvironmentConfig
lateinit var surveySparrowLauncher: SurveySparrowLauncher
@Composable
override fun ScreenContent(modifier: Modifier) {
@ -217,29 +216,16 @@ internal class TesterActivity : ComposeActivity() {
}
private fun startSurveySparrow(): Boolean {
val token = environmentConfig.surveySparrowToken
if (token.isNullOrEmpty()) {
val toast = Toast.makeText(
this,
"Survey Sparrow is not configured. Token is missing.",
Toast.LENGTH_LONG,
)
toast.show()
return false
}
SurveySparrowManager(domain = DOMAIN, token = token).startSurveyForResult(
surveySparrowLauncher.present(
activity = this,
requestCode = SURVEY_SPARROW_REQUEST_CODE,
data = SurveyLaunchData(domain = DOMAIN, token = TEST_SHARE_TOKEN, customParams = emptyMap()),
)
return true
}
private companion object {
const val DOMAIN = "tangem.com"
const val SURVEY_SPARROW_REQUEST_CODE = 1001
const val DOMAIN = "tangem.surveysparrow.com"
const val TEST_SHARE_TOKEN = "ntt-84iF22PDajmervYneMW4kv"
}
}

View file

@ -1,55 +0,0 @@
package com.tangem.feature.tester.presentation.surveysparrow
import android.app.Activity
import com.surveysparrow.ss_android_sdk.SsSurvey
import com.surveysparrow.ss_android_sdk.SurveySparrow
import com.tangem.utils.logging.TangemLogger
/**
* Manager for Survey Sparrow SDK.
*
* @param domain Survey Sparrow domain (e.g., "yourcompany")
* @param token Survey Sparrow SDK token
*/
class SurveySparrowManager(
private val domain: String,
private val token: String,
) {
/**
* Create a SurveySparrow instance to start a survey.
*
* @param activity The activity context
* @param customVariables Optional custom variables to pass to the survey
* @return SurveySparrow instance ready to start
*/
fun createSurvey(activity: Activity, customVariables: Map<String, String>? = null): SurveySparrow? {
return try {
val survey = SsSurvey(domain, token).apply {
customVariables?.forEach { (key, value) ->
addCustomParam(key, value)
}
}
SurveySparrow(activity, survey)
} catch (e: Exception) {
TangemLogger.e("Failed to create SurveySparrow survey", e)
null
}
}
/**
* Start a survey for result.
*
* @param activity The activity context
* @param requestCode The request code for onActivityResult
* @param customVariables Optional custom variables to pass to the survey
*/
fun startSurveyForResult(activity: Activity, requestCode: Int, customVariables: Map<String, String>? = null) {
val surveySparrow = createSurvey(activity, customVariables)
if (surveySparrow != null) {
surveySparrow.startSurveyForResult(requestCode)
TangemLogger.d("SurveySparrow survey started with requestCode: $requestCode")
}
}
}

View file

@ -259,6 +259,9 @@ include(":features:rating:impl")
include(":features:stories:api")
include(":features:stories:impl")
include(":features:survey:api")
include(":features:survey:impl")
include(":features:txhistory:api")
include(":features:txhistory:impl")