Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-29 18:34:21 +05:00
parent 3b6855578c
commit bfdb3a2966
14 changed files with 194 additions and 12 deletions

View file

@ -127,7 +127,7 @@
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="onramp-success"
android:host="*"
android:scheme="tangem" />
</intent-filter>

View file

@ -9,6 +9,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.event.TechAnalyticsEvent
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.deeplink.DeepLinksRegistry
import com.tangem.core.ui.R
import com.tangem.core.ui.coil.ImagePreloader
import com.tangem.core.ui.extensions.resourceReference
@ -31,6 +32,7 @@ import com.tangem.domain.settings.usercountry.FetchUserCountryUseCase
import com.tangem.domain.staking.FetchStakingTokensUseCase
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.feature.swap.analytics.StoriesEvents
import com.tangem.features.onramp.deeplink.OnrampDeepLink
import com.tangem.tap.common.extensions.setContext
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.features.onboarding.products.wallet.redux.BackupDialog
@ -63,6 +65,8 @@ internal class MainViewModel @Inject constructor(
private val imagePreloader: ImagePreloader,
private val fetchHotCryptoUseCase: FetchHotCryptoUseCase,
private val onboardingRepository: OnboardingRepository,
private val deepLinksRegistry: DeepLinksRegistry,
private val onrampDeepLinkFactory: OnrampDeepLink.Factory,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
) : ViewModel() {
@ -97,6 +101,8 @@ internal class MainViewModel @Inject constructor(
sendKeyboardIdentifierEvent()
preloadImages()
initializeDeepLinks()
}
fun checkForUnfinishedBackup() {
@ -333,4 +339,8 @@ internal class MainViewModel @Inject constructor(
)
}
}
private fun initializeDeepLinks() {
deepLinksRegistry.register(onrampDeepLinkFactory.create(viewModelScope))
}
}

View file

@ -63,7 +63,7 @@ interface OnrampApi {
@GET("onramp-data")
suspend fun getData(
@Header("user-id") userWalletId: String,
@Header("refcode") refCode: String,
@Header("refcode") refCode: String?,
@Query("fromCurrencyCode") fromCurrencyCode: String,
@Query("fromPrecision") fromPrecision: Int,
@Query("toContractAddress") toContractAddress: String,

View file

@ -1,5 +1,6 @@
package com.tangem.data.onramp
import android.net.Uri
import com.squareup.moshi.Moshi
import com.tangem.blockchain.extensions.toBigDecimalOrDefault
import com.tangem.data.common.api.safeApiCall
@ -349,6 +350,7 @@ internal class DefaultOnrampRepository(
val currency = requireNotNull(getDefaultCurrencySync()) { "Default currency must not be null" }
val country = requireNotNull(getDefaultCountrySync()) { "Default country must not be null" }
val requestId = UUID.randomUUID().toString()
val redirectUrl = quote.provider.toRedirectUrl() ?: error("Invalid onramp redirect url")
val data = safeApiCall(
call = {
onrampApi.getData(
@ -362,7 +364,7 @@ internal class DefaultOnrampRepository(
toDecimals = cryptoCurrency.decimals,
providerId = quote.provider.id,
toAddress = address,
redirectUrl = "",
redirectUrl = redirectUrl,
language = null,
theme = getTheme(isDarkTheme),
requestId = requestId,
@ -370,7 +372,7 @@ internal class DefaultOnrampRepository(
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
).takeUnless { it.isEmpty() },
).bind()
},
onError = { e ->
@ -544,6 +546,11 @@ internal class DefaultOnrampRepository(
null
}
private fun OnrampProvider.toRedirectUrl() = Uri.Builder()
.path(REDIRECT_URL)
.appendPath(id)
.build().path
private companion object {
const val PAYMENT_METHODS_KEY = "onramp_payment_methods"
const val SELECTED_PAYMENT_METHOD_KEY = "onramp_selected_payment_method"
@ -553,5 +560,7 @@ internal class DefaultOnrampRepository(
const val CURRENCIES_KEY = "onramp_currencies"
const val PROVIDER_THEME_DARK = "dark"
const val PROVIDER_THEME_LIGHT = "light"
const val REDIRECT_URL = "https://tangem.com/onramp"
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.onramp
import arrow.core.Either
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.cache.OnrampTransaction
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.onramp.repositories.OnrampErrorResolver
import com.tangem.domain.onramp.repositories.OnrampRepository
@ -20,7 +21,7 @@ class GetOnrampRedirectUrlUseCase(
quote: OnrampProviderWithQuote.Data,
cryptoCurrency: CryptoCurrency,
isDarkTheme: Boolean,
): Either<OnrampError, String> {
): Either<OnrampError, OnrampTransaction> {
return Either.catch {
val transaction = repository.getOnrampData(
userWallet = userWallet,
@ -29,7 +30,7 @@ class GetOnrampRedirectUrlUseCase(
isDarkTheme = isDarkTheme,
)
transactionRepository.storeTransaction(transaction)
transaction.redirectUrl
transaction
}.mapLeft(errorResolver::resolve)
}
}

View file

@ -13,6 +13,7 @@ dependencies {
/* Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.deepLinks)
/* Project - Domain */
implementation(projects.domain.onramp.models)

View file

@ -0,0 +1,12 @@
package com.tangem.features.onramp.deeplink
import com.tangem.core.deeplink.DeepLink
import kotlinx.coroutines.CoroutineScope
abstract class OnrampDeepLink : DeepLink() {
override val uri = "tangem://onramp"
interface Factory {
fun create(coroutineScope: CoroutineScope): OnrampDeepLink
}
}

View file

@ -22,6 +22,7 @@ dependencies {
implementation(projects.core.analytics)
implementation(projects.core.configToggles)
implementation(projects.core.decompose)
implementation(projects.core.deepLinks)
implementation(projects.core.navigation)
implementation(projects.core.ui)
implementation(projects.core.utils)

View file

@ -0,0 +1,48 @@
package com.tangem.features.onramp.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.features.onramp.success.OnrampSuccessScreenTrigger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
internal class DefaultOnrampDeepLink @AssistedInject constructor(
private val appRouter: AppRouter,
private val onrampSuccessScreenTrigger: OnrampSuccessScreenTrigger,
@Assisted private val scope: CoroutineScope,
) : OnrampDeepLink() {
override fun onReceive(params: Map<String, String>) {
val txId = params[TX_ID_KEY]
val result = OnrampRedirectResult.getResult(params[RESULT_KEY])
when {
!txId.isNullOrEmpty() -> {
// finish current onramp flow and show onramp success screen
val replaceOnrampScreens = appRouter.stack
.filterNot { it is AppRoute.Onramp }
.toMutableList()
replaceOnrampScreens.add(AppRoute.OnrampSuccess(txId))
appRouter.replaceAll(*replaceOnrampScreens.toTypedArray())
}
result != OnrampRedirectResult.Unknown -> {
scope.launch {
onrampSuccessScreenTrigger.triggerOnrampSuccess(result == OnrampRedirectResult.Success)
}
}
}
}
@AssistedFactory
interface Factory : OnrampDeepLink.Factory {
override fun create(coroutineScope: CoroutineScope): DefaultOnrampDeepLink
}
private companion object {
const val TX_ID_KEY = "tx_id"
const val RESULT_KEY = "result"
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.onramp.deeplink
enum class OnrampRedirectResult(val value: String) {
Success("success"),
Cancel("cancel"),
Unknown(""),
;
companion object {
fun getResult(result: String?): OnrampRedirectResult {
return entries.firstOrNull { it.value == result } ?: Unknown
}
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.onramp.deeplink.di
import com.tangem.features.onramp.deeplink.DefaultOnrampDeepLink
import com.tangem.features.onramp.deeplink.OnrampDeepLink
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 OnrampDeeplinkModule {
@Binds
@Singleton
fun bindFactory(impl: DefaultOnrampDeepLink.Factory): OnrampDeepLink.Factory
}

View file

@ -1,9 +1,10 @@
package com.tangem.features.onramp.redirect.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
@ -13,14 +14,18 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.onramp.GetOnrampRedirectUrlUseCase
import com.tangem.domain.onramp.model.cache.OnrampTransaction
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.redirect.OnrampRedirectComponent
import com.tangem.features.onramp.redirect.entity.OnrampRedirectTopBarUM
import com.tangem.features.onramp.redirect.entity.OnrampRedirectUM
import com.tangem.features.onramp.success.OnrampSuccessScreenListener
import com.tangem.features.onramp.utils.sendOnrampErrorEvent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ -30,16 +35,19 @@ internal class OnrampRedirectModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val urlOpener: UrlOpener,
private val getOnrampRedirectUrlUseCase: GetOnrampRedirectUrlUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val messageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
router: Router,
private val onrampSuccessScreenListener: OnrampSuccessScreenListener,
private val appRouter: AppRouter,
paramsContainer: ParamsContainer,
getWalletsUseCase: GetWalletsUseCase,
) : Model() {
private val params: OnrampRedirectComponent.Params = paramsContainer.require()
private val selectedUserWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
private var latestOnrampTransaction: OnrampTransaction? = null
val state = OnrampRedirectUM(
topBarConfig = OnrampRedirectTopBarUM(
title = combinedReference(
@ -48,7 +56,7 @@ internal class OnrampRedirectModel @Inject constructor(
),
startButtonUM = TopAppBarButtonUM(
iconRes = R.drawable.ic_close_24,
onIconClicked = router::pop,
onIconClicked = appRouter::pop,
enabled = true,
),
),
@ -63,6 +71,10 @@ internal class OnrampRedirectModel @Inject constructor(
),
)
init {
subscribeToOnrampSuccessListener()
}
fun getRedirectUrl(isDarkTheme: Boolean) {
modelScope.launch {
getOnrampRedirectUrlUseCase.invoke(
@ -73,12 +85,31 @@ internal class OnrampRedirectModel @Inject constructor(
)
.onLeft(::handleError)
.onRight {
params.onBack()
urlOpener.openUrl(it)
latestOnrampTransaction = it
urlOpener.openUrl(it.redirectUrl)
}
}
}
private fun subscribeToOnrampSuccessListener() {
onrampSuccessScreenListener.onrampSuccessTriggerFlow
.onEach { result ->
val latestTxId = latestOnrampTransaction?.txId
if (latestTxId != null && result) {
// Finish current onramp flow and show onramp success screen
val replaceOnrampScreens = appRouter.stack
.filterNot { it is AppRoute.Onramp }
.toMutableList()
replaceOnrampScreens.add(AppRoute.OnrampSuccess(latestTxId))
appRouter.replaceAll(*replaceOnrampScreens.toTypedArray())
} else {
// Close redirect screen and show last onramp state
params.onBack()
}
}
.launchIn(modelScope)
}
private fun handleError(error: OnrampError) {
Timber.e(error.toString())
analyticsEventHandler.sendOnrampErrorEvent(

View file

@ -0,0 +1,26 @@
package com.tangem.features.onramp.success
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
interface OnrampSuccessScreenListener {
val onrampSuccessTriggerFlow: Flow<Boolean>
}
interface OnrampSuccessScreenTrigger {
suspend fun triggerOnrampSuccess(result: Boolean)
}
@Singleton
internal class DefaultOnrampSuccessScreenTrigger @Inject constructor() :
OnrampSuccessScreenTrigger,
OnrampSuccessScreenListener {
override val onrampSuccessTriggerFlow = MutableSharedFlow<Boolean>()
override suspend fun triggerOnrampSuccess(result: Boolean) {
onrampSuccessTriggerFlow.emit(result)
}
}

View file

@ -2,6 +2,9 @@ package com.tangem.features.onramp.success.di
import com.tangem.features.onramp.component.OnrampSuccessComponent
import com.tangem.features.onramp.success.DefaultOnrampSuccessComponent
import com.tangem.features.onramp.success.DefaultOnrampSuccessScreenTrigger
import com.tangem.features.onramp.success.OnrampSuccessScreenListener
import com.tangem.features.onramp.success.OnrampSuccessScreenTrigger
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -16,4 +19,12 @@ internal interface OnrampSuccessComponentModule {
fun bindOnrampSuccessComponentFactory(
factory: DefaultOnrampSuccessComponent.Factory,
): OnrampSuccessComponent.Factory
@Binds
@Singleton
fun bindOnrampSuccessScreenTrigger(impl: DefaultOnrampSuccessScreenTrigger): OnrampSuccessScreenTrigger
@Binds
@Singleton
fun bindOnrampSuccessScreenListener(impl: DefaultOnrampSuccessScreenTrigger): OnrampSuccessScreenListener
}