Updated on 2026-08-14
This commit is contained in:
parent
40893754c5
commit
b319350dd5
5 changed files with 299 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue