Updated on 2026-08-14

This commit is contained in:
Tangem 2025-05-19 14:26:21 +05:00
parent c4603c4cbd
commit c613890b9a
8 changed files with 241 additions and 13 deletions

View file

@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
@ -30,6 +31,7 @@ import com.tangem.common.routing.entity.SerializableIntent
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.di.RootAppComponentContext
import com.tangem.core.deeplink.DEEPLINK_KEY
import com.tangem.core.deeplink.DeepLinksRegistry
import com.tangem.core.navigation.email.EmailSender
import com.tangem.core.ui.UiDependencies
@ -68,6 +70,7 @@ import com.tangem.tap.proxy.AppStateHolder
import com.tangem.tap.proxy.redux.DaggerGraphAction
import com.tangem.tap.routing.component.RoutingComponent
import com.tangem.tap.routing.configurator.AppRouterConfig
import com.tangem.tap.routing.utils.DeepLinkFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.FeatureCoroutineExceptionHandler
import dagger.hilt.android.AndroidEntryPoint
@ -175,6 +178,9 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
@Inject
internal lateinit var routingFeatureToggle: RoutingFeatureToggle
@Inject
internal lateinit var deeplinkFactory: DeepLinkFactory
internal val viewModel: MainViewModel by viewModels()
private lateinit var appThemeModeFlow: SharedFlow<AppThemeMode>
@ -227,13 +233,9 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
sendStakingUnsubmittedHashes()
checkGoogleServicesAvailability()
if (intent != null && savedInstanceState == null) {
if (routingFeatureToggle.isDeepLinkNavigationEnabled.not() && intent != null && savedInstanceState == null) {
// handle intent only on start, not on recreate
if (routingFeatureToggle.isDeepLinkNavigationEnabled) {
// todo [REDACTED_TASK_KEY]
} else {
deepLinksRegistry.launch(intent)
}
handleDeepLink(intent)
}
lifecycle.addObserver(WindowObscurationObserver)
@ -382,11 +384,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
}
if (intent != null) {
if (routingFeatureToggle.isDeepLinkNavigationEnabled) {
// todo [REDACTED_TASK_KEY]
} else {
deepLinksRegistry.launch(intent)
}
handleDeepLink(intent)
}
}
@ -467,9 +465,24 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
}
}
if (routingFeatureToggle.isDeepLinkNavigationEnabled && intent != null) {
handleDeepLink(intent)
}
viewModel.checkForUnfinishedBackup()
}
private fun handleDeepLink(intent: Intent) {
if (routingFeatureToggle.isDeepLinkNavigationEnabled) {
val deepLinkExtras = intent.getStringExtra(DEEPLINK_KEY)?.toUri()
val receivedDeepLink = intent.data ?: deepLinkExtras ?: return
deeplinkFactory.handleDeeplink(deeplinkUri = receivedDeepLink, coroutineScope = lifecycleScope)
} else {
deepLinksRegistry.launch(intent)
}
}
private fun observePolkadotAccountHealthCheck() {
lifecycleScope.launch {
getPolkadotCheckHasResetUseCase()

View file

@ -22,6 +22,7 @@ import com.tangem.tap.routing.component.RoutingComponent
import com.tangem.tap.routing.component.RoutingComponent.Child
import com.tangem.tap.routing.configurator.AppRouterConfig
import com.tangem.tap.routing.utils.ChildFactory
import com.tangem.tap.routing.utils.DeepLinkFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -34,6 +35,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val appRouterConfig: AppRouterConfig,
private val uiDependencies: UiDependencies,
private val wcRoutingComponentFactory: WcRoutingComponent.Factory,
private val deeplinkFactory: DeepLinkFactory,
) : RoutingComponent,
AppComponentContext by context,
SnackbarHandler {
@ -60,7 +62,10 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
stack.subscribe(lifecycle) { stack ->
val stackItems = stack.items.map { it.configuration }
wcRoutingComponent.onAppRouteChange(stack.active.configuration)
deeplinkFactory.checkRoutingReadiness(stack.active.configuration)
if (appRouterConfig.stack != stackItems) {
appRouterConfig.stack = stackItems
}

View file

@ -0,0 +1,123 @@
package com.tangem.tap.routing.utils
import android.net.Uri
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.DeepLinkRoute
import com.tangem.common.routing.DeepLinkScheme
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.transformLatest
import timber.log.Timber
import javax.inject.Inject
@ActivityScoped
internal class DeepLinkFactory @Inject constructor(
private val onrampDeepLink: OnrampDeepLinkHandler.Factory,
) {
private val permittedAppRoute = MutableStateFlow(false)
private var lastDeepLink: Uri? = null
private val deepLinkHandlerJobHolder = JobHolder()
@OptIn(ExperimentalCoroutinesApi::class)
fun handleDeeplink(deeplinkUri: Uri, coroutineScope: CoroutineScope) {
lastDeepLink = deeplinkUri
Timber.i(
"""
Received deep link intent
|- Received URI: $deeplinkUri
""".trimIndent(),
)
permittedAppRoute
.transformLatest<Boolean, Unit> { isPermitted ->
if (isPermitted) {
lastDeepLink?.let {
launchDeepLink(it, coroutineScope)
}
lastDeepLink = null
}
}
.launchIn(coroutineScope)
.saveIn(deepLinkHandlerJobHolder)
}
/**
* Check if app is ready to handle deeplink
*/
fun checkRoutingReadiness(appRoute: AppRoute) {
permittedAppRoute.value = when (appRoute) {
AppRoute.Initial,
AppRoute.Home,
is AppRoute.Welcome,
is AppRoute.Disclaimer,
is AppRoute.Stories,
is AppRoute.Onboarding,
-> false
else -> true
}
}
private fun launchDeepLink(deeplinkUri: Uri, coroutineScope: CoroutineScope) {
when (deeplinkUri.scheme) {
DeepLinkScheme.Tangem.scheme -> handleTangemDeepLinks(deeplinkUri, coroutineScope)
else -> {
Timber.i(
"""
No match found for deep link
|- Received URI: $deeplinkUri
""".trimIndent(),
)
}
}
}
private fun handleTangemDeepLinks(deeplinkUri: Uri, coroutineScope: CoroutineScope) {
val params = getParams(deeplinkUri)
when (deeplinkUri.host) {
DeepLinkRoute.Onramp.host -> onrampDeepLink.create(coroutineScope, params)
else -> {
Timber.i(
"""
No match found for deep link
|- Received URI: $deeplinkUri
|- With params: $params
""".trimIndent(),
)
}
}
}
private fun getParams(uri: Uri): Map<String, String> {
val params = mutableMapOf<String, String>()
uri.queryParameterNames.forEach { paramName ->
val paramValue = uri.getQueryParameter(paramName)
if (paramName.validate() && paramValue?.validate() == true) {
params[paramName] = paramValue
}
}
return params
}
/**
* Check for malicious symbol in uri part
*/
private fun String.validate(): Boolean {
val regex = DEEPLINK_VALIDATION_REGEX.toRegex()
return !regex.containsMatchIn(this)
}
private companion object {
const val DEEPLINK_VALIDATION_REGEX = "['\";<>()+\\\\]"
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.common.routing
sealed class DeepLinkRoute {
abstract val host: String
data object Onramp : DeepLinkRoute() {
override val host: String = "onramp"
}
}
enum class DeepLinkScheme(val scheme: String) {
Tangem(scheme = "tangem"),
WalletConnect(scheme = "wc"),
}

View file

@ -3,10 +3,18 @@ package com.tangem.features.onramp.deeplink
import com.tangem.core.deeplink.DeepLink
import kotlinx.coroutines.CoroutineScope
@Deprecated("Use OnrampDeepLinkHandler")
abstract class OnrampDeepLink : DeepLink() {
override val uri = "tangem://onramp"
interface Factory {
fun create(coroutineScope: CoroutineScope): OnrampDeepLink
}
}
interface OnrampDeepLinkHandler {
interface Factory {
fun create(coroutineScope: CoroutineScope, params: Map<String, String>): OnrampDeepLinkHandler
}
}

View file

@ -0,0 +1,58 @@
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
import timber.log.Timber
class DefaultOnrampDeepLinkHandler @AssistedInject constructor(
appRouter: AppRouter,
private val onrampSuccessScreenTrigger: OnrampSuccessScreenTrigger,
@Assisted private val scope: CoroutineScope,
@Assisted params: Map<String, String>,
) : OnrampDeepLinkHandler {
init {
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 || it is AppRoute.OnrampSuccess }
.toMutableList() + AppRoute.OnrampSuccess(txId)
appRouter.replaceAll(*replaceOnrampScreens.toTypedArray())
}
result != OnrampRedirectResult.Unknown -> {
scope.launch {
onrampSuccessScreenTrigger.triggerOnrampSuccess(result == OnrampRedirectResult.Success)
}
}
else -> {
Timber.e(
"""
Invalid parameters for ONRAMP deeplink
|- Params: $params
""".trimIndent(),
)
}
}
}
@AssistedFactory
interface Factory : OnrampDeepLinkHandler.Factory {
override fun create(coroutineScope: CoroutineScope, params: Map<String, String>): DefaultOnrampDeepLinkHandler
}
private companion object {
const val TX_ID_KEY = "tx_id"
const val RESULT_KEY = "result"
}
}

View file

@ -1,7 +1,7 @@
package com.tangem.features.onramp.deeplink.di
import com.tangem.features.onramp.deeplink.*
import com.tangem.features.onramp.deeplink.DefaultOnrampDeepLink
import com.tangem.features.onramp.deeplink.OnrampDeepLink
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -15,4 +15,8 @@ internal interface OnrampDeeplinkModule {
@Binds
@Singleton
fun bindFactory(impl: DefaultOnrampDeepLink.Factory): OnrampDeepLink.Factory
@Binds
@Singleton
fun bindFactoryV2(impl: DefaultOnrampDeepLinkHandler.Factory): OnrampDeepLinkHandler.Factory
}

View file

@ -153,7 +153,9 @@ internal class OnrampSuccessComponentModel @Inject constructor(
} else {
resourceReference(R.string.express_error_code, wrappedList(errorCode))
},
onDismissRequest = router::pop,
firstActionBuilder = {
okAction(router::pop)
},
)
messageSender.send(message)