diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22f93e0530..9d754f3add 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f6139be58a..36cca67394 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -210,6 +210,17 @@ android:scheme="tangem" /> + + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt b/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt index 0b6f9c503e..61ac194a7f 100644 --- a/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt +++ b/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt @@ -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) } -} \ No newline at end of file +} + +internal class UnresolvedDeeplinkException(uri: Uri) : + RuntimeException("Deeplink has no matching activity: scheme=${uri.scheme}, host=${uri.host}") \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt index 492904d18a..d8cb34aece 100644 --- a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt +++ b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt @@ -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) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index c1d690f533..41205cfc6e 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -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, diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 72fdba54ab..e886e10e14 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -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( """ diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index b256302118..1650ffedea 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -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(relaxed = true) { + every { create(any()) } returns mockk() + } + private val earnDeepLinkFactory = mockk(relaxed = true) { every { create(any()) } returns mockk() } @@ -140,6 +145,7 @@ class DeepLinkFactoryTest { newsDeepLink = newsDeepLinkFactory, earnDeepLink = earnDeepLinkFactory, yieldDeepLink = yieldDeepLinkFactory, + surveyDeepLink = surveyDeepLinkFactory, ) @OptIn(ExperimentalCoroutinesApi::class) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 30438d4e34..02d03d772d 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -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, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index ee03ee94b4..e2e31a626c 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -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) { diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index d465fd6778..2fe3cc2043 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -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" diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt index 7abc5a48bd..108b6fe3e5 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/model/PromoBannersBlockModel.kt @@ -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" + } } \ No newline at end of file diff --git a/features/survey/api/build.gradle.kts b/features/survey/api/build.gradle.kts new file mode 100644 index 0000000000..a0db7dc04e --- /dev/null +++ b/features/survey/api/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyComponent.kt b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyComponent.kt new file mode 100644 index 0000000000..4fe9360d56 --- /dev/null +++ b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyComponent.kt @@ -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 +} \ No newline at end of file diff --git a/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyFeatureToggles.kt b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyFeatureToggles.kt new file mode 100644 index 0000000000..c42ffbf905 --- /dev/null +++ b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveyFeatureToggles.kt @@ -0,0 +1,6 @@ +package com.tangem.features.survey + +interface SurveyFeatureToggles { + + val areSurveysEnabled: Boolean +} \ No newline at end of file diff --git a/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveySparrowLauncher.kt b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveySparrowLauncher.kt new file mode 100644 index 0000000000..84d518d39e --- /dev/null +++ b/features/survey/api/src/main/kotlin/com/tangem/features/survey/SurveySparrowLauncher.kt @@ -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, +) \ No newline at end of file diff --git a/features/survey/api/src/main/kotlin/com/tangem/features/survey/deeplink/SurveyDeepLinkHandler.kt b/features/survey/api/src/main/kotlin/com/tangem/features/survey/deeplink/SurveyDeepLinkHandler.kt new file mode 100644 index 0000000000..a1d6abc36d --- /dev/null +++ b/features/survey/api/src/main/kotlin/com/tangem/features/survey/deeplink/SurveyDeepLinkHandler.kt @@ -0,0 +1,8 @@ +package com.tangem.features.survey.deeplink + +interface SurveyDeepLinkHandler { + + interface Factory { + fun create(queryParams: Map): SurveyDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/survey/impl/build.gradle.kts b/features/survey/impl/build.gradle.kts new file mode 100644 index 0000000000..e512a05413 --- /dev/null +++ b/features/survey/impl/build.gradle.kts @@ -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().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) +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyComponent.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyComponent.kt new file mode 100644 index 0000000000..bc06782acc --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyComponent.kt @@ -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" + } +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyFeatureToggles.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyFeatureToggles.kt new file mode 100644 index 0000000000..6959c5bc9e --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveyFeatureToggles.kt @@ -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) +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveySparrowLauncher.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveySparrowLauncher.kt new file mode 100644 index 0000000000..eb648e78b8 --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/DefaultSurveySparrowLauncher.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandler.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandler.kt new file mode 100644 index 0000000000..013015eb8a --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandler.kt @@ -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, + 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): DefaultSurveyDeepLinkHandler + } + + private companion object { + const val TAG = "SurveyDeepLink" + const val QUERY_TOKEN = "token" + const val QUERY_DISPLAY_ID = "display_id" + } +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/di/SurveyModule.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/di/SurveyModule.kt new file mode 100644 index 0000000000..2af50ede8b --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/di/SurveyModule.kt @@ -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 +} \ No newline at end of file diff --git a/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilder.kt b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilder.kt new file mode 100644 index 0000000000..d5d905d0b4 --- /dev/null +++ b/features/survey/impl/src/main/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilder.kt @@ -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 { + 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" + } +} \ No newline at end of file diff --git a/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandlerTest.kt b/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandlerTest.kt new file mode 100644 index 0000000000..e3c1345e03 --- /dev/null +++ b/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/deeplink/DefaultSurveyDeepLinkHandlerTest.kt @@ -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() + private val appRouter = mockk(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) = DefaultSurveyDeepLinkHandler( + queryParams = queryParams, + surveyFeatureToggles = featureToggles, + appRouter = appRouter, + ) + + private companion object { + const val TOKEN = "ntt-84iF22PDajmervYneMW4kv" + const val DISPLAY_ID = "42" + } +} \ No newline at end of file diff --git a/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilderTest.kt b/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilderTest.kt new file mode 100644 index 0000000000..290c70adca --- /dev/null +++ b/features/survey/impl/src/test/kotlin/com/tangem/features/survey/impl/service/SurveyCustomParamsBuilderTest.kt @@ -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() + private val appInfoProvider = mockk() + + 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 { 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" + } +} \ No newline at end of file diff --git a/features/tester/impl/build.gradle.kts b/features/tester/impl/build.gradle.kts index f96c63f4b4..b3e1f1c4b4 100644 --- a/features/tester/impl/build.gradle.kts +++ b/features/tester/impl/build.gradle.kts @@ -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) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt index 3829e306b7..a1ede089f5 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt @@ -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" } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/surveysparrow/SurveySparrowManager.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/surveysparrow/SurveySparrowManager.kt deleted file mode 100644 index 3b2385900c..0000000000 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/surveysparrow/SurveySparrowManager.kt +++ /dev/null @@ -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? = 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? = null) { - val surveySparrow = createSurvey(activity, customVariables) - if (surveySparrow != null) { - surveySparrow.startSurveyForResult(requestCode) - TangemLogger.d("SurveySparrow survey started with requestCode: $requestCode") - } - } -} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 4c1a0fb251..590257dd05 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")