diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 13f1801543..b61add5332 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -176,6 +176,16 @@ android:scheme="tangem" /> + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index 9e44bb3dc2..557f69fdd0 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -33,7 +33,9 @@ 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.deeplink.WEBLINK_KEY import com.tangem.core.navigation.email.EmailSender +import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.UiDependencies import com.tangem.data.balancehiding.DefaultDeviceFlipDetector import com.tangem.data.card.sdk.CardSdkOwner @@ -74,6 +76,7 @@ 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 com.tangem.utils.extensions.uriValidate import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -185,6 +188,9 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { @Inject internal lateinit var walletConnectFeatureToggles: WalletConnectFeatureToggles + @Inject + internal lateinit var urlOpener: UrlOpener + internal val viewModel: MainViewModel by viewModels() private lateinit var appThemeModeFlow: SharedFlow @@ -239,7 +245,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { if (routingFeatureToggle.isDeepLinkNavigationEnabled.not() && intent != null && savedInstanceState == null) { // handle intent only on start, not on recreate - handleDeepLink(intent) + handleDeepLink(intent = intent, isFromOnNewIntent = false) } lifecycle.addObserver(WindowObscurationObserver) @@ -391,7 +397,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { } if (intent != null) { - handleDeepLink(intent) + handleDeepLink(intent = intent, isFromOnNewIntent = true) } } @@ -473,18 +479,31 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { } if (routingFeatureToggle.isDeepLinkNavigationEnabled && intent != null) { - handleDeepLink(intent) + handleDeepLink(intent = intent, isFromOnNewIntent = false) } viewModel.checkForUnfinishedBackup() } - private fun handleDeepLink(intent: Intent) { + private fun handleDeepLink(intent: Intent, isFromOnNewIntent: Boolean) { if (routingFeatureToggle.isDeepLinkNavigationEnabled) { val deepLinkExtras = intent.getStringExtra(DEEPLINK_KEY)?.toUri() - val receivedDeepLink = intent.data ?: deepLinkExtras ?: return + val webLink = intent.getStringExtra(WEBLINK_KEY) - deeplinkFactory.handleDeeplink(deeplinkUri = receivedDeepLink, coroutineScope = lifecycleScope) + val receivedDeepLink = intent.data ?: deepLinkExtras + + when { + receivedDeepLink != null -> { + deeplinkFactory.handleDeeplink( + deeplinkUri = receivedDeepLink, + coroutineScope = lifecycleScope, + isFromOnNewIntent = isFromOnNewIntent, + ) + } + webLink?.uriValidate() == true -> { + urlOpener.openUrl(webLink) + } + } } else { deepLinksRegistry.launch(intent) } diff --git a/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt b/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt index 8f984c76f0..23a6725255 100644 --- a/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt +++ b/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt @@ -16,6 +16,7 @@ import coil.request.ImageRequest import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.tangem.core.deeplink.DEEPLINK_KEY +import com.tangem.core.deeplink.WEBLINK_KEY import com.tangem.domain.common.LogConfig import com.tangem.tap.MainActivity import com.tangem.tap.common.images.createCoilImageLoader @@ -39,6 +40,7 @@ internal class TangemPushNotificationService : FirebaseMessagingService() { val intent = Intent(applicationContext, MainActivity::class.java).apply { putExtra(DEEPLINK_KEY, message.data[DEEPLINK_KEY]) + putExtra(WEBLINK_KEY, message.data[WEBLINK_KEY]) putExtra(OnPushClickedIntentHandler.OPENED_FROM_GCM_PUSH, true) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } 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 45bde26900..00e016b292 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 @@ -8,11 +8,13 @@ import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler import com.tangem.features.send.v2.api.deeplink.SellDeepLinkHandler +import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler import com.tangem.features.walletconnect.components.deeplink.WalletConnectDeepLinkHandler import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn +import com.tangem.utils.extensions.uriValidate import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -32,6 +34,7 @@ internal class DeepLinkFactory @Inject constructor( private val walletConnectDeepLink: WalletConnectDeepLinkHandler.Factory, private val walletDeepLink: WalletDeepLinkHandler.Factory, private val tokenDetailsDeepLink: TokenDetailsDeepLinkHandler.Factory, + private val stakingDeepLink: StakingDeepLinkHandler.Factory, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -39,7 +42,7 @@ internal class DeepLinkFactory @Inject constructor( private val deepLinkHandlerJobHolder = JobHolder() @OptIn(ExperimentalCoroutinesApi::class) - fun handleDeeplink(deeplinkUri: Uri, coroutineScope: CoroutineScope) { + fun handleDeeplink(deeplinkUri: Uri, coroutineScope: CoroutineScope, isFromOnNewIntent: Boolean) { lastDeepLink = deeplinkUri Timber.i( @@ -52,7 +55,7 @@ internal class DeepLinkFactory @Inject constructor( .transformLatest { isPermitted -> if (isPermitted) { lastDeepLink?.let { - launchDeepLink(it, coroutineScope) + launchDeepLink(it, coroutineScope, isFromOnNewIntent) } lastDeepLink = null } @@ -77,9 +80,9 @@ internal class DeepLinkFactory @Inject constructor( } } - private fun launchDeepLink(deeplinkUri: Uri, coroutineScope: CoroutineScope) { + private fun launchDeepLink(deeplinkUri: Uri, coroutineScope: CoroutineScope, isFromOnNewIntent: Boolean) { when (deeplinkUri.scheme) { - DeepLinkScheme.Tangem.scheme -> handleTangemDeepLinks(deeplinkUri, coroutineScope) + DeepLinkScheme.Tangem.scheme -> handleTangemDeepLinks(deeplinkUri, coroutineScope, isFromOnNewIntent) DeepLinkScheme.WalletConnect.scheme -> walletConnectDeepLink.create(deeplinkUri) else -> { Timber.i( @@ -92,7 +95,7 @@ internal class DeepLinkFactory @Inject constructor( } } - private fun handleTangemDeepLinks(deeplinkUri: Uri, coroutineScope: CoroutineScope) { + private fun handleTangemDeepLinks(deeplinkUri: Uri, coroutineScope: CoroutineScope, isFromOnNewIntent: Boolean) { val queryParams = getQueryParams(deeplinkUri) when (deeplinkUri.host) { DeepLinkRoute.Onramp.host -> onrampDeepLink.create(coroutineScope, queryParams) @@ -100,7 +103,12 @@ internal class DeepLinkFactory @Inject constructor( DeepLinkRoute.Buy.host -> buyDeepLink.create(coroutineScope) DeepLinkRoute.Referral.host -> referralDeepLink.create() DeepLinkRoute.Wallet.host -> walletDeepLink.create() - DeepLinkRoute.TokenDetails.host -> tokenDetailsDeepLink.create(coroutineScope, queryParams) + DeepLinkRoute.TokenDetails.host -> tokenDetailsDeepLink.create( + coroutineScope = coroutineScope, + queryParams = queryParams, + isFromOnNewIntent = isFromOnNewIntent, + ) + DeepLinkRoute.Staking.host -> stakingDeepLink.create(coroutineScope, queryParams) else -> { Timber.i( """ @@ -119,24 +127,11 @@ internal class DeepLinkFactory @Inject constructor( uri.queryParameterNames.forEach { paramName -> val paramValue = uri.getQueryParameter(paramName) - if (paramName.validate() && paramValue?.validate() == true) { + if (paramName.uriValidate() && paramValue?.uriValidate() == 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 = "['\";<>()+\\\\]" - } } \ No newline at end of file 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 32be06bd2f..40a82fad7e 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 @@ -6,6 +6,7 @@ import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler import com.tangem.features.send.v2.api.deeplink.SellDeepLinkHandler +import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkHandler import com.tangem.features.walletconnect.components.deeplink.WalletConnectDeepLinkHandler @@ -43,10 +44,14 @@ class DeepLinkFactoryTest { every { create() } returns mockk() } private val tokenDetailsDeepLinkFactory = mockk(relaxed = true) { + every { create(any(), any(), any()) } returns mockk() + } + private val stakingDeepLinkFactory = mockk(relaxed = true) { every { create(any(), any()) } returns mockk() } private val mockedUri = mockk(relaxed = true) + private val isFromOnNewIntent: Boolean = false private lateinit var testDispatcher: TestDispatcher private lateinit var testScope: TestScope @@ -59,6 +64,7 @@ class DeepLinkFactoryTest { walletConnectDeepLinkFactory, walletDeepLinkFactory, tokenDetailsDeepLinkFactory, + stakingDeepLinkFactory, ) @OptIn(ExperimentalCoroutinesApi::class) @@ -96,7 +102,7 @@ class DeepLinkFactoryTest { every { mockedUri.getQueryParameter("param") } returns "value" // Set permittedAppRoute to true - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) deepLinkFactory.checkRoutingReadiness(AppRoute.Wallet) advanceUntilIdle() @@ -115,7 +121,7 @@ class DeepLinkFactoryTest { every { mockedUri.queryParameterNames } returns setOf("param") every { mockedUri.getQueryParameter("param") } returns "value" - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) deepLinkFactory.checkRoutingReadiness(AppRoute.Initial) advanceUntilIdle() @@ -133,7 +139,7 @@ class DeepLinkFactoryTest { every { mockedUri.getQueryParameter("param") } returns "value" deepLinkFactory.checkRoutingReadiness(AppRoute.Wallet) - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() @@ -148,7 +154,7 @@ class DeepLinkFactoryTest { every { mockedUri.host } returns "" deepLinkFactory.checkRoutingReadiness(AppRoute.Wallet) - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() @@ -162,7 +168,7 @@ class DeepLinkFactoryTest { every { mockedUri.scheme } returns "https" every { mockedUri.host } returns "example.com" - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() @@ -173,7 +179,7 @@ class DeepLinkFactoryTest { referralDeepLinkFactory.create() walletConnectDeepLinkFactory.create(any()) walletDeepLinkFactory.create() - tokenDetailsDeepLinkFactory.create(any(), any()) + tokenDetailsDeepLinkFactory.create(any(), any(), any()) } } @@ -188,21 +194,39 @@ class DeepLinkFactoryTest { // Test Onramp every { mockedUri.host } returns "onramp" - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() verify { onrampDeepLinkFactory.create(eq(testScope), eq(mapOf("param" to "value"))) } // Test Sell every { mockedUri.host } returns "redirect_sell" - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() verify { sellDeepLinkFactory.create(eq(testScope), eq(mapOf("param" to "value"))) } // Test Token Details every { mockedUri.host } returns "token" - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() - verify { tokenDetailsDeepLinkFactory.create(eq(testScope), eq(mapOf("param" to "value"))) } + verify { + tokenDetailsDeepLinkFactory.create( + eq(testScope), + eq(mapOf("param" to "value")), + eq(isFromOnNewIntent), + ) + } + + // Test Staking + every { mockedUri.host } returns "staking" + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) + advanceUntilIdle() + verify { + tokenDetailsDeepLinkFactory.create( + eq(testScope), + eq(mapOf("param" to "value")), + eq(isFromOnNewIntent), + ) + } // Reset params every { mockedUri.queryParameterNames } returns emptySet() @@ -210,19 +234,19 @@ class DeepLinkFactoryTest { // Test Buy every { mockedUri.host } returns "redirect" - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() verify { buyDeepLinkFactory.create(eq(testScope)) } // Test Referral every { mockedUri.host } returns "referral" - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() verify { referralDeepLinkFactory.create() } // Test Wallet every { mockedUri.host } returns "main" - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() verify { walletDeepLinkFactory.create() } } @@ -233,7 +257,7 @@ class DeepLinkFactoryTest { every { mockedUri.host } returns "unknown" deepLinkFactory.checkRoutingReadiness(AppRoute.Wallet) - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() @@ -244,7 +268,7 @@ class DeepLinkFactoryTest { referralDeepLinkFactory.create() walletConnectDeepLinkFactory.create(any()) walletDeepLinkFactory.create() - tokenDetailsDeepLinkFactory.create(any(), any()) + tokenDetailsDeepLinkFactory.create(any(), any(), any()) } } @@ -259,7 +283,7 @@ class DeepLinkFactoryTest { every { mockedUri.getQueryParameter("quote") } returns "O'Brien" deepLinkFactory.checkRoutingReadiness(AppRoute.Wallet) - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() verify { onrampDeepLinkFactory.create(eq(testScope), eq(mapOf("safe" to "ok"))) } @@ -275,49 +299,49 @@ class DeepLinkFactoryTest { every { mockedUri.query } returns "safe=ok" every { mockedUri.queryParameterNames } returns setOf("safe") every { mockedUri.getQueryParameter("safe") } returns "ok" - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() verify { onrampDeepLinkFactory.create(eq(testScope), eq(mapOf("safe" to "ok"))) } every { mockedUri.query } returns "param123=ok" every { mockedUri.queryParameterNames } returns setOf("param123") every { mockedUri.getQueryParameter("param123") } returns "ok" - deepLinkFactory.handleDeeplink(mockedUri, testScope) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() verify { onrampDeepLinkFactory.create(eq(testScope), eq(mapOf("param123" to "ok"))) } every { mockedUri.query } returns "unsafe=