diff --git a/app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt b/app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt index cfd160dab2..83fd5c3d97 100644 --- a/app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt +++ b/app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt @@ -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 diff --git a/app/src/main/java/com/tangem/tap/common/pushes/PushMessageHandler.kt b/app/src/main/java/com/tangem/tap/common/pushes/PushMessageHandler.kt new file mode 100644 index 0000000000..d8589b644f --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/pushes/PushMessageHandler.kt @@ -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) { + 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 { + val params = mutableMapOf() + queryParameterNames.forEach { name -> + val value = getQueryParameter(name) + if (name.uriValidate() && value?.uriValidate() == true) { + params[name] = value + } + } + return params + } +} \ No newline at end of file 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 aef6c2946b..86cc8de6f0 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 @@ -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 diff --git a/app/src/main/java/com/tangem/tap/common/pushes/TokenDetailsPushHandler.kt b/app/src/main/java/com/tangem/tap/common/pushes/TokenDetailsPushHandler.kt new file mode 100644 index 0000000000..c370c505ce --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/pushes/TokenDetailsPushHandler.kt @@ -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) { + // 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) { + 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) + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/common/pushes/TokenDetailsPushHandlerTest.kt b/app/src/test/kotlin/com/tangem/tap/common/pushes/TokenDetailsPushHandlerTest.kt new file mode 100644 index 0000000000..84779067f5 --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/common/pushes/TokenDetailsPushHandlerTest.kt @@ -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(), + 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): AccountList = AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = currencies, + ) + + private fun mockCryptoCurrency() = mockk { + 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"), + ) + } +} \ No newline at end of file