Updated on 2026-08-14
This commit is contained in:
parent
bb5d6848d9
commit
0ee2f48898
29 changed files with 660 additions and 82 deletions
61
features/survey/impl/build.gradle.kts
Normal file
61
features/survey/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue