Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-02 16:35:29 +05:00
parent 40893754c5
commit b319350dd5
5 changed files with 299 additions and 0 deletions

View file

@ -4,11 +4,18 @@ import android.os.Bundle
import com.huawei.hms.push.HmsMessageService
import com.huawei.hms.push.RemoteMessage
import com.tangem.google.GoogleServicesHelper
import com.tangem.tap.common.pushes.PushMessageHandler
import com.tangem.tap.common.pushes.PushNotificationDelegate
import com.tangem.utils.logging.TangemLogger
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class HuaweiPushService : HmsMessageService() {
@Inject
internal lateinit var pushMessageHandler: PushMessageHandler
private val pushNotificationDelegate: PushNotificationDelegate by lazy {
PushNotificationDelegate(applicationContext)
}
@ -27,6 +34,9 @@ class HuaweiPushService : HmsMessageService() {
super.onMessageReceived(message)
val isGoogleServicesAvailable = GoogleServicesHelper.checkGoogleServicesAvailability(this)
if (isGoogleServicesAvailable) return
message?.dataOfMap?.let(pushMessageHandler::onMessageReceived)
val notification = message?.notification ?: return
val channelId = notification.channelId ?: TANGEM_CHANNEL_ID

View file

@ -0,0 +1,42 @@
package com.tangem.tap.common.pushes
import android.net.Uri
import androidx.core.net.toUri
import com.tangem.common.routing.DeepLinkRoute
import com.tangem.common.routing.deeplink.PayloadToDeeplinkConverter
import com.tangem.utils.extensions.uriValidate
import javax.inject.Inject
/**
* Routes pushes received while the app is running to the matching in-app handler.
*
* Converts the push payload to a deeplink (via [PayloadToDeeplinkConverter]) and routes by its
* [host][Uri.getHost] the same routing key [DeepLinkFactory][com.tangem.tap.routing.utils.DeepLinkFactory] uses
* for tapped deeplinks. Handlers receive the deeplink query params (not the raw payload), so both flat-key and
* `deeplink`-style payloads are handled uniformly. Each handler owns its own reaction; add a `when` branch per
* push type as new in-app reactions appear.
*/
internal class PushMessageHandler @Inject constructor(
private val tokenDetailsPushHandler: TokenDetailsPushHandler,
) {
fun onMessageReceived(data: Map<String, String>) {
val deeplink = PayloadToDeeplinkConverter.convert(data)?.toUri() ?: return
val queryParams = deeplink.getQueryParams()
when (deeplink.host) {
DeepLinkRoute.TokenDetails.host -> tokenDetailsPushHandler.handle(queryParams)
else -> Unit
}
}
private fun Uri.getQueryParams(): Map<String, String> {
val params = mutableMapOf<String, String>()
queryParameterNames.forEach { name ->
val value = getQueryParameter(name)
if (name.uriValidate() && value?.uriValidate() == true) {
params[name] = value
}
}
return params
}
}

View file

@ -4,11 +4,17 @@ import android.annotation.SuppressLint
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.tangem.utils.logging.TangemLogger
import dagger.hilt.android.AndroidEntryPoint
import io.customer.messagingpush.CustomerIOFirebaseMessagingService
import javax.inject.Inject
@AndroidEntryPoint
@SuppressLint("MissingFirebaseInstanceTokenRefresh")
internal class TangemPushNotificationService : FirebaseMessagingService() {
@Inject
lateinit var pushMessageHandler: PushMessageHandler
private val pushNotificationDelegate: PushNotificationDelegate by lazy {
PushNotificationDelegate(applicationContext)
}
@ -29,6 +35,8 @@ internal class TangemPushNotificationService : FirebaseMessagingService() {
handleNotificationTrigger = false,
)
pushMessageHandler.onMessageReceived(message.data)
val notification = message.notification ?: return
val channelId = notification.channelId ?: TANGEM_CHANNEL_ID

View file

@ -0,0 +1,81 @@
package com.tangem.tap.common.pushes
import com.tangem.common.routing.deeplink.DeeplinkConst.DERIVATION_PATH_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.domain.account.fetcher.SingleAccountListFetcher
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.tap.ForegroundActivityObserver
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Handles a received token-details push (same payload as
* [com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler]).
*
* When the app is open and the pushed token is not yet present in the wallet's portfolio (e.g. it was just added
* on the backend), refreshes the wallet accounts so it appears locally the open portfolio screen then updates
* reactively via [SingleAccountListSupplier]. Does nothing else.
*/
class TokenDetailsPushHandler @Inject constructor(
private val appCoroutineScope: AppCoroutineScope,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val singleAccountListFetcher: SingleAccountListFetcher,
) {
fun handle(queryParams: Map<String, String>) {
// Only when the app is open: a token just added on the backend should appear in the already-open portfolio.
// On cold start the fresh list is loaded by the regular auth flow instead.
if (ForegroundActivityObserver.foregroundActivity == null) return
appCoroutineScope.launch { refreshPortfolioIfTokenMissing(queryParams) }
}
internal suspend fun refreshPortfolioIfTokenMissing(queryParams: Map<String, String>) {
val networkId = queryParams[NETWORK_ID_KEY] ?: return
val tokenId = queryParams[TOKEN_ID_KEY] ?: return
val derivationPath = queryParams[DERIVATION_PATH_KEY]
val userWallet = resolveUserWallet(queryParams[WALLET_ID_KEY]) ?: return
// Token list refresh only makes sense for an unlocked multi-currency wallet.
if (userWallet.isLocked || !userWallet.isMultiCurrency) return
val isTokenPresent = singleAccountListSupplier.getSyncOrNull(userWallet.walletId)
?.flattenCurrencies()
?.any { it.matches(networkId = networkId, tokenId = tokenId, derivationPath = derivationPath) } == true
if (isTokenPresent) return
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId = userWallet.walletId))
.onLeft { TangemLogger.e("Error on refreshing portfolio from push", it) }
}
private fun resolveUserWallet(walletId: String?): UserWallet? {
val userWalletId = walletId?.let(::UserWalletId)
return if (userWalletId != null) {
getUserWalletUseCase(userWalletId).getOrNull()
} else {
getSelectedWalletSyncUseCase().getOrNull()
}
}
private fun CryptoCurrency.matches(networkId: String, tokenId: String, derivationPath: String?): Boolean {
val isNetwork = network.rawId.equals(networkId, ignoreCase = true)
val isCurrency = id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
val isDefaultDerivation = network.derivationPath is Network.DerivationPath.Card
val isCustomDerivation = derivationPath?.equals(network.derivationPath.value) == true
return isNetwork && isCurrency && (isDefaultDerivation || isCustomDerivation)
}
}

View file

@ -0,0 +1,158 @@
package com.tangem.tap.common.pushes
import arrow.core.Either
import com.tangem.common.routing.deeplink.DeeplinkConst.DERIVATION_PATH_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.domain.account.fetcher.SingleAccountListFetcher
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class TokenDetailsPushHandlerTest {
private val getUserWalletUseCase: GetUserWalletUseCase = mockk()
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase = mockk()
private val singleAccountListSupplier: SingleAccountListSupplier = mockk()
private val singleAccountListFetcher: SingleAccountListFetcher = mockk()
private val handler = TokenDetailsPushHandler(
appCoroutineScope = mockk<AppCoroutineScope>(),
getUserWalletUseCase = getUserWalletUseCase,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
singleAccountListSupplier = singleAccountListSupplier,
singleAccountListFetcher = singleAccountListFetcher,
)
private val userWalletId = UserWalletId("011")
@BeforeEach
fun setUp() {
mockkObject(TangemLogger)
coEvery { singleAccountListFetcher.invoke(any()) } returns Either.Right(Unit)
}
@Test
fun `GIVEN token absent in portfolio WHEN handle push THEN refresh accounts`() = runTest {
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(multiCurrencyWallet())
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns accountList(currencies = emptyList())
handler.refreshPortfolioIfTokenMissing(defaultData())
coVerify { singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId)) }
}
@Test
fun `GIVEN token present in portfolio WHEN handle push THEN do not refresh`() = runTest {
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(multiCurrencyWallet())
coEvery {
singleAccountListSupplier.getSyncOrNull(userWalletId)
} returns accountList(currencies = listOf(mockCryptoCurrency()))
handler.refreshPortfolioIfTokenMissing(defaultData())
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
}
@Test
fun `GIVEN no wallet id in payload WHEN handle push THEN refresh selected wallet`() = runTest {
every { getSelectedWalletSyncUseCase.invoke() } returns Either.Right(multiCurrencyWallet())
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns accountList(currencies = emptyList())
handler.refreshPortfolioIfTokenMissing(defaultData() - WALLET_ID_KEY)
coVerify { singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId)) }
}
@Test
fun `GIVEN no wallet id and no selected wallet WHEN handle push THEN do not refresh`() = runTest {
every { getSelectedWalletSyncUseCase.invoke() } returns Either.Left(GetUserWalletError.UserWalletNotFound)
handler.refreshPortfolioIfTokenMissing(defaultData() - WALLET_ID_KEY)
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
}
@Test
fun `GIVEN locked wallet WHEN handle push THEN do not refresh`() = runTest {
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk { every { isLocked } returns true },
)
handler.refreshPortfolioIfTokenMissing(defaultData())
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
}
@Test
fun `GIVEN single currency wallet WHEN handle push THEN do not refresh`() = runTest {
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isLocked } returns false
every { isMultiCurrency } returns false
},
)
handler.refreshPortfolioIfTokenMissing(defaultData())
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
}
@Test
fun `GIVEN wallet not found WHEN handle push THEN do not refresh`() = runTest {
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Left(
value = GetUserWalletError.UserWalletNotFound,
)
handler.refreshPortfolioIfTokenMissing(defaultData())
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
}
private fun defaultData() = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
DERIVATION_PATH_KEY to "777",
)
private fun multiCurrencyWallet(): UserWallet = mockk {
every { isLocked } returns false
every { isMultiCurrency } returns true
every { walletId } returns userWalletId
}
private fun accountList(currencies: List<CryptoCurrency>): AccountList = AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = currencies,
)
private fun mockCryptoCurrency() = mockk<CryptoCurrency> {
every { network } returns mockk {
every { rawId } returns "123"
every { derivationPath } returns Network.DerivationPath.Card(value = "777")
}
every { id } returns CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(rawId = "321", derivationPath = "777"),
suffix = CryptoCurrency.ID.Suffix.RawID("321"),
)
}
}