Updated on 2026-08-14

This commit is contained in:
Tangem 2025-06-03 13:12:04 +04:00
parent 3bcb5280fb
commit 7703832cf1
17 changed files with 393 additions and 23 deletions

View file

@ -17,6 +17,7 @@ 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.core.deeplink.converter.PayloadToDeeplinkConverter
import com.tangem.domain.common.LogConfig
import com.tangem.tap.MainActivity
import com.tangem.tap.common.images.createCoilImageLoader
@ -37,9 +38,10 @@ internal class TangemPushNotificationService : FirebaseMessagingService() {
val notification = message.notification ?: return
val channelId = notification.channelId ?: TANGEM_CHANNEL_ID
val deeplink = PayloadToDeeplinkConverter.convert(message.data)
val intent = Intent(applicationContext, MainActivity::class.java).apply {
putExtra(DEEPLINK_KEY, message.data[DEEPLINK_KEY])
putExtra(DEEPLINK_KEY, deeplink)
putExtra(WEBLINK_KEY, message.data[WEBLINK_KEY])
putExtra(OnPushClickedIntentHandler.OPENED_FROM_GCM_PUSH, true)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)

View file

@ -9,6 +9,7 @@ data class NotificationApplicationCreateBody(
@Json(name = "platform") val platform: String? = null,
@Json(name = "device") val device: String? = null,
@Json(name = "systemVersion") val systemVersion: String? = null,
@Json(name = "version") val version: String? = null,
@Json(name = "language") val language: String? = null,
@Json(name = "timezone") val timezone: String? = null,
)

View file

@ -10,6 +10,9 @@ android {
}
dependencies {
/* Common */
implementation(projects.common.routing)
/* Core */
implementation(projects.core.decompose)
@ -22,4 +25,10 @@ dependencies {
/* DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/* Tests */
testImplementation(deps.test.junit)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
}

View file

@ -0,0 +1,11 @@
package com.tangem.core.deeplink
object DeeplinkConst {
@Deprecated("Used only for push notifications mapping, use `WALLET_ID_KEY` only")
const val PAYLOAD_WALLET_ID_KEY = "user_wallet_id"
const val TANGEM_SCHEME = "tangem"
const val WALLET_ID_KEY = "walletId"
const val NETWORK_ID_KEY = "network_id"
const val TYPE_KEY = "type"
const val TOKEN_ID_KEY = "token_id"
}

View file

@ -0,0 +1,64 @@
package com.tangem.core.deeplink.converter
import com.tangem.core.deeplink.DeeplinkConst.TANGEM_SCHEME
/**
* Builder class for constructing deep links with a fluent interface.
*/
internal class DeepLinkBuilder {
private var scheme: String = TANGEM_SCHEME
private var action: String = ""
private val pathParams: MutableList<String> = mutableListOf()
private val queryParams: MutableMap<String, String> = mutableMapOf()
/**
* Sets the scheme for the deep link (e.g., "tangem", "https")
*/
fun setScheme(scheme: String): DeepLinkBuilder {
this.scheme = scheme
return this
}
/**
* Sets the action for the deep link (e.g., "link", "wallet")
*/
fun setAction(action: String): DeepLinkBuilder {
this.action = action
return this
}
/**
* Adds a path parameter to the deep link
*/
fun addPathParam(param: String): DeepLinkBuilder {
pathParams.add(param)
return this
}
/**
* Adds a query parameter to the deep link
*/
fun addQueryParam(key: String, value: String): DeepLinkBuilder {
queryParams[key] = value
return this
}
/**
* Builds the deep link URI string
*/
fun build(): String {
val path = if (pathParams.isEmpty()) {
action
} else {
"$action/${pathParams.joinToString("/")}"
}
val queryString = if (queryParams.isEmpty()) {
""
} else {
"?" + queryParams.entries.joinToString("&") { "${it.key}=${it.value}" }
}
return "$scheme://$path$queryString"
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.core.deeplink.converter
import com.tangem.common.routing.DeepLinkRoute
import com.tangem.common.routing.DeepLinkScheme
import com.tangem.core.deeplink.DEEPLINK_KEY
import com.tangem.core.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.PAYLOAD_WALLET_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TYPE_KEY
import com.tangem.core.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.utils.converter.Converter
object PayloadToDeeplinkConverter : Converter<Map<String, String>, String?> {
override fun convert(value: Map<String, String>): String? {
return when {
value[DEEPLINK_KEY] != null -> value[DEEPLINK_KEY]
isTangemPushNotificationPayload(value) -> buildNotificationDeeplink(value)
else -> null
}
}
@Suppress("ReturnCount")
private fun buildNotificationDeeplink(payload: Map<String, String>): String? {
val type = payload[TYPE_KEY] ?: return null
val networkId = payload[NETWORK_ID_KEY] ?: return null
val tokenId = payload[TOKEN_ID_KEY] ?: return null
val walletId = payload[PAYLOAD_WALLET_ID_KEY] ?: return null
return DeepLinkBuilder().setScheme(DeepLinkScheme.Tangem.scheme)
.setAction(DeepLinkRoute.TokenDetails.host)
.addQueryParam(NETWORK_ID_KEY, networkId)
.addQueryParam(TOKEN_ID_KEY, tokenId)
.addQueryParam(TYPE_KEY, type)
.addQueryParam(WALLET_ID_KEY, walletId)
.build()
}
private fun isTangemPushNotificationPayload(payload: Map<String, String>): Boolean {
return payload.containsKey(TYPE_KEY) &&
payload.containsKey(NETWORK_ID_KEY) &&
payload.containsKey(TOKEN_ID_KEY) &&
payload.containsKey(PAYLOAD_WALLET_ID_KEY)
}
}

View file

@ -0,0 +1,112 @@
package com.tangem.core.deeplink.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.core.deeplink.DeeplinkConst
import org.junit.Before
import org.junit.Test
internal class DeepLinkBuilderTest {
private lateinit var deepLinkBuilder: DeepLinkBuilder
@Before
fun setup() {
deepLinkBuilder = DeepLinkBuilder()
}
@Test
fun `GIVEN default builder WHEN build THEN should return default scheme`() {
// WHEN
val result = deepLinkBuilder.build()
// THEN
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://")
}
@Test
fun `GIVEN custom scheme WHEN setScheme THEN should use custom scheme`() {
// GIVEN
val customScheme = "https"
// WHEN
val result = deepLinkBuilder
.setScheme(customScheme)
.build()
// THEN
assertThat(result).isEqualTo("$customScheme://")
}
@Test
fun `GIVEN action WHEN setAction THEN should include action in path`() {
// GIVEN
val action = "wallet"
// WHEN
val result = deepLinkBuilder
.setAction(action)
.build()
// THEN
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://$action")
}
@Test
fun `GIVEN path params WHEN addPathParam THEN should include params in path`() {
// GIVEN
val action = "wallet"
val param1 = "123"
val param2 = "456"
// WHEN
val result = deepLinkBuilder
.setAction(action)
.addPathParam(param1)
.addPathParam(param2)
.build()
// THEN
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://$action/$param1/$param2")
}
@Test
fun `GIVEN query params WHEN addQueryParam THEN should include params in query string`() {
// GIVEN
val action = "wallet"
val key1 = "param1"
val value1 = "value1"
val key2 = "param2"
val value2 = "value2"
// WHEN
val result = deepLinkBuilder
.setAction(action)
.addQueryParam(key1, value1)
.addQueryParam(key2, value2)
.build()
// THEN
assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://$action?$key1=$value1&$key2=$value2")
}
@Test
fun `GIVEN complex deep link WHEN build THEN should construct correct URI`() {
// GIVEN
val scheme = "https"
val action = "wallet"
val pathParam = "123"
val queryKey = "token"
val queryValue = "abc"
// WHEN
val result = deepLinkBuilder
.setScheme(scheme)
.setAction(action)
.addPathParam(pathParam)
.addQueryParam(queryKey, queryValue)
.build()
// THEN
assertThat(result).isEqualTo("$scheme://$action/$pathParam?$queryKey=$queryValue")
}
}

View file

@ -0,0 +1,123 @@
package com.tangem.core.deeplink.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.core.deeplink.DEEPLINK_KEY
import com.tangem.core.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.PAYLOAD_WALLET_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TYPE_KEY
import org.junit.Test
internal class PayloadToDeeplinkConverterTest {
@Test
fun `GIVEN payload with deeplink key WHEN convert THEN should return deeplink value`() {
// GIVEN
val payload = mapOf(
DEEPLINK_KEY to "tangem://token-details?networkId=ethereum&tokenId=0x123&type=token&walletId=wallet123",
)
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isEqualTo(
"tangem://token-details?networkId=ethereum&tokenId=0x123&type=token&walletId=wallet123",
)
}
@Test
fun `GIVEN valid push notification payload with all vital values WHEN convert THEN should return correct deeplink`() {
// GIVEN
val payload = mapOf(
TYPE_KEY to "token",
NETWORK_ID_KEY to "ethereum",
TOKEN_ID_KEY to "0x123",
PAYLOAD_WALLET_ID_KEY to "wallet123",
)
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isEqualTo(
"tangem://token?network_id=ethereum&token_id=0x123&type=token&walletId=wallet123",
)
}
@Test
fun `GIVEN push notification payload with missing type WHEN convert THEN should return null`() {
// GIVEN
val payload = mapOf(
NETWORK_ID_KEY to "ethereum",
TOKEN_ID_KEY to "0x123",
PAYLOAD_WALLET_ID_KEY to "wallet123",
)
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN push notification payload with missing networkId WHEN convert THEN should return null`() {
// GIVEN
val payload = mapOf(
TYPE_KEY to "token",
TOKEN_ID_KEY to "0x123",
PAYLOAD_WALLET_ID_KEY to "wallet123",
)
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN push notification payload with missing tokenId WHEN convert THEN should return null`() {
// GIVEN
val payload = mapOf(
TYPE_KEY to "token",
NETWORK_ID_KEY to "ethereum",
PAYLOAD_WALLET_ID_KEY to "wallet123",
)
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN push notification payload with missing walletId WHEN convert THEN should return null`() {
// GIVEN
val payload = mapOf(
TYPE_KEY to "token",
NETWORK_ID_KEY to "ethereum",
TOKEN_ID_KEY to "0x123",
)
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN empty payload WHEN convert THEN should return null`() {
// GIVEN
val payload = emptyMap<String, String>()
// WHEN
val result = PayloadToDeeplinkConverter.convert(payload)
// THEN
assertThat(result).isNull()
}
}

View file

@ -17,7 +17,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@Composable
fun NetworkNameAndSymbolItem(icon: Int, name: String, symbol: String, modifier: Modifier = Modifier) {
fun ItemWithIconAndSubtext(icon: Int, name: String, symbol: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier.padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
@ -43,7 +43,7 @@ fun NetworkNameAndSymbolItem(icon: Int, name: String, symbol: String, modifier:
}
@Composable
fun NetworkNameAndSymbolItemShimmer(modifier: Modifier = Modifier) {
fun ItemWithIconAndSubtextShimmer(modifier: Modifier = Modifier) {
Row(
modifier = modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
@ -66,8 +66,8 @@ fun NetworkNameAndSymbolItemShimmer(modifier: Modifier = Modifier) {
private fun ContentPreview() {
TangemThemePreview {
Column {
NetworkNameAndSymbolItemShimmer()
NetworkNameAndSymbolItem(
ItemWithIconAndSubtextShimmer()
ItemWithIconAndSubtext(
icon = R.drawable.ic_solana_16,
name = "Solana",
symbol = "SOL",

View file

@ -25,11 +25,12 @@ internal class DefaultNotificationsRepository @Inject constructor(
override suspend fun createApplicationId(pushToken: String?): ApplicationId = withContext(dispatchers.io) {
tangemTechApi.createApplicationId(
NotificationApplicationCreateBody(
platform = appInfoProvider.platform,
platform = appInfoProvider.platform.lowercase(),
device = appInfoProvider.device,
systemVersion = appInfoProvider.osVersion,
language = appInfoProvider.language,
timezone = appInfoProvider.timezone,
version = appInfoProvider.appVersion,
pushToken = pushToken,
),
).getOrThrow().appId.let(::ApplicationId)

View file

@ -50,6 +50,7 @@ class DefaultNotificationsRepositoryTest {
coEvery { appInfoProvider.device } returns "test-device"
coEvery { appInfoProvider.osVersion } returns "11"
coEvery { appInfoProvider.language } returns "en"
coEvery { appInfoProvider.appVersion } returns "5.21.1"
coEvery { appInfoProvider.timezone } returns "UTC"
coEvery { tangemTechApi.createApplicationId(any()) } returns ApiResponse.Success(
expectedAppIdResponse,
@ -68,6 +69,7 @@ class DefaultNotificationsRepositoryTest {
systemVersion = "11",
language = "en",
timezone = "UTC",
version = "5.21.1",
pushToken = pushToken,
),
)

View file

@ -2,6 +2,9 @@ package com.tangem.domain.notifications.models
enum class NotificationType(val type: String) {
Promo("promo"),
IncomeTransactions("income_transaction"),
SwapStatus("swap_status_update"),
OnrampStatus("onramp_status_update"),
Unknown("unknown"),
;

View file

@ -46,7 +46,7 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor(
Timber.e("Error on getting crypto currency list")
return@launch
}.firstOrNull {
it.id.rawNetworkId == networkId && it.id.rawCurrencyId?.value == tokenId
it.network.backendId == networkId && it.id.rawCurrencyId?.value == tokenId
}
if (cryptoCurrency == null) {

View file

@ -3,6 +3,10 @@ package com.tangem.feature.tokendetails.deeplink
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.core.deeplink.DeeplinkConst.TYPE_KEY
import com.tangem.core.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.domain.notifications.models.NotificationType
import com.tangem.domain.tokens.FetchCurrencyStatusUseCase
import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase
@ -45,13 +49,14 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
val tokenId = queryParams[TOKEN_ID_KEY]
val type = NotificationType.getType(queryParams[TYPE_KEY])
if (type == NotificationType.Promo) {
if (type != NotificationType.Unknown) {
scope.launch {
val cryptoCurrency = getCryptoCurrenciesUseCase(userWalletId = userWalletId).getOrElse {
Timber.e("Error on getting crypto currency list")
return@launch
}.firstOrNull {
it.id.rawNetworkId == networkId && it.id.rawCurrencyId?.value == tokenId
it.network.backendId == networkId &&
it.id.rawCurrencyId?.value == tokenId
}
if (cryptoCurrency == null) {
@ -91,11 +96,4 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
isFromOnNewIntent: Boolean,
): DefaultTokenDetailsDeepLinkHandler
}
private companion object {
const val WALLET_ID_KEY = "walletId"
const val NETWORK_ID_KEY = "network_id"
const val TYPE_KEY = "type"
const val TOKEN_ID_KEY = "token_id"
}
}

View file

@ -87,8 +87,7 @@ internal class DefaultWalletSettingsComponent @AssistedInject constructor(
): ComposableBottomSheetComponent = networksAvailableForNotificationsComponent.create(
context = childByContext(componentContext),
params = NetworksAvailableForNotificationsComponent.Params(
onDismiss = model
.bottomSheetNavigation::dismiss,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)

View file

@ -21,8 +21,8 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter
import com.tangem.core.ui.components.items.NetworkNameAndSymbolItem
import com.tangem.core.ui.components.items.NetworkNameAndSymbolItemShimmer
import com.tangem.core.ui.components.items.ItemWithIconAndSubtext
import com.tangem.core.ui.components.items.ItemWithIconAndSubtextShimmer
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -136,12 +136,12 @@ private fun Networks(
)
if (isLoading) {
repeat(SHIMMERS_COUNT) {
NetworkNameAndSymbolItemShimmer()
ItemWithIconAndSubtextShimmer()
}
} else {
networks.fastForEach { network ->
key(network.id) {
NetworkNameAndSymbolItem(
ItemWithIconAndSubtext(
icon = network.icon,
name = network.name,
symbol = network.symbol,

View file

@ -3,7 +3,7 @@ package com.tangem.feature.walletsettings.ui.state
import com.tangem.domain.notifications.models.NotificationsEligibleNetwork
import kotlinx.collections.immutable.ImmutableList
data class NetworksAvailableForNotificationsUM(
internal data class NetworksAvailableForNotificationsUM(
val networks: ImmutableList<NotificationsEligibleNetwork>,
val isLoading: Boolean,
)