Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-22 17:19:42 +05:00
parent ebbce95832
commit 896da1def0
20 changed files with 564 additions and 9 deletions

View file

@ -5,10 +5,10 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class NotificationApplicationCreateBody( data class NotificationApplicationCreateBody(
@Json(name = "pushToken") val pushToken: String, @Json(name = "pushToken") val pushToken: String? = null,
@Json(name = "platform") val platform: String, @Json(name = "platform") val platform: String? = null,
@Json(name = "device") val device: String, @Json(name = "device") val device: String? = null,
@Json(name = "systemVersion") val systemVersion: String, @Json(name = "systemVersion") val systemVersion: String? = null,
@Json(name = "language") val language: String, @Json(name = "language") val language: String? = null,
@Json(name = "timezone") val timezone: String, @Json(name = "timezone") val timezone: String? = null,
) )

View file

@ -5,6 +5,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class WalletBody( data class WalletBody(
@Json(name = "notifyStatus") val notifyStatus: String? = null, @Json(name = "notifyStatus") val notifyStatus: Boolean? = null,
@Json(name = "name") val name: String? = null, @Json(name = "name") val name: String? = null,
) )

View file

@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class WalletResponse( data class WalletResponse(
@Json(name = "notifyStatus") val notifyStatus: String? = null, @Json(name = "notifyStatus") val notifyStatus: Boolean,
@Json(name = "name") val name: String? = null,
@Json(name = "id") val id: String, @Json(name = "id") val id: String,
@Json(name = "name") val name: String? = null,
) )

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.di
import com.tangem.datasource.info.AndroidAppInfoProvider
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.version.AppVersionProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object AppInfoModule {
@Singleton
@Provides
fun provideAppInfoProvider(appVersionProvider: AppVersionProvider): AppInfoProvider {
return AndroidAppInfoProvider(appVersionProvider)
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.datasource.info
import android.os.Build
import com.tangem.utils.SupportedLanguages
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.version.AppVersionProvider
import java.util.*
import javax.inject.Inject
internal class AndroidAppInfoProvider @Inject constructor(
private val appVersionProvider: AppVersionProvider,
) : AppInfoProvider {
override val platform: String
get() = "Android"
override val device: String
get() = "${Build.MANUFACTURER} ${Build.MODEL}"
override val osVersion: String
get() = Build.VERSION.RELEASE
override val language: String
get() = SupportedLanguages.getCurrentSupportedLanguageCode()
override val timezone: String
get() = TimeZone.getDefault().id
override val appVersion: String
get() = appVersionProvider.versionName
}

View file

@ -123,6 +123,10 @@ object PreferencesKeys {
val WALLETS_NFT_ENABLED_STATES_KEY by lazy { stringPreferencesKey(name = "walletsNftEnabledStates") } val WALLETS_NFT_ENABLED_STATES_KEY by lazy { stringPreferencesKey(name = "walletsNftEnabledStates") }
// region Notifications
val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") }
// endregion
fun getShouldShowStoriesKey(storyId: String) = booleanPreferencesKey("shouldShowStories_$storyId") fun getShouldShowStoriesKey(storyId: String) = booleanPreferencesKey("shouldShowStories_$storyId")
// region Permission // region Permission

View file

@ -0,0 +1,10 @@
package com.tangem.utils.info
interface AppInfoProvider {
val platform: String
val device: String
val osVersion: String
val language: String
val timezone: String
val appVersion: String
}

1
data/notifications/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,46 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.notifications"
}
dependencies {
// region AndroidX libraries
implementation(deps.androidx.datastore)
implementation(deps.kotlin.coroutines)
// endregion
// region DI
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
// endregion
// region Arrow
implementation(deps.arrow.core)
// endregion
// region Core modules
implementation(projects.core.datasource)
implementation(projects.core.utils)
// endregion
// region Domain modules
implementation(projects.domain.notifications.models)
implementation(projects.domain.notifications)
// endregion
// region tests
testImplementation(deps.test.junit)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
testImplementation(deps.moshi)
testImplementation(deps.moshi.kotlin)
// endregion
}

View file

@ -0,0 +1,96 @@
package com.tangem.data.notifications
import com.tangem.data.notifications.converters.NotificationsEligibleNetworkConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.NotificationApplicationCreateBody
import com.tangem.datasource.api.tangemTech.models.WalletBody
import com.tangem.datasource.api.tangemTech.models.WalletIdBody
import com.tangem.utils.info.AppInfoProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.*
import com.tangem.domain.notifications.NotificationsRepository
import com.tangem.domain.notifications.models.NotificationsEligibleNetwork
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class DefaultNotificationsRepository @Inject constructor(
private val tangemTechApi: TangemTechApi,
private val appInfoProvider: AppInfoProvider,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : NotificationsRepository {
override suspend fun createApplicationId(pushToken: String?): String = withContext(dispatchers.io) {
tangemTechApi.createApplicationId(
NotificationApplicationCreateBody(
platform = appInfoProvider.platform,
device = appInfoProvider.device,
systemVersion = appInfoProvider.osVersion,
language = appInfoProvider.language,
timezone = appInfoProvider.timezone,
pushToken = pushToken,
),
).getOrThrow().appId
}
override suspend fun saveApplicationId(appId: String) {
appPreferencesStore.store(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY, appId)
}
override suspend fun getApplicationId(): String? {
return appPreferencesStore.getSyncOrNull(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY)
}
override suspend fun setNotificationsEnabledForWallet(walletId: String, enabled: Boolean) =
withContext(dispatchers.io) {
tangemTechApi.setNotificationsEnabled(
walletId = walletId,
body = WalletBody(notifyStatus = enabled),
).getOrThrow()
}
override suspend fun associateApplicationIdWithWallets(appId: String, wallets: List<String>) =
withContext(dispatchers.io) {
tangemTechApi.associateApplicationIdWithWallets(
applicationId = appId,
body = wallets.map {
WalletIdBody(it)
},
).getOrThrow()
}
override suspend fun isNotificationsEnabledForWallet(walletId: String): Boolean = withContext(dispatchers.io) {
tangemTechApi.getWalletById(walletId).getOrThrow().notifyStatus
}
override suspend fun setWalletName(walletId: String, walletName: String) = withContext(dispatchers.io) {
tangemTechApi.updateWallet(
walletId,
WalletBody(name = walletName),
).getOrThrow()
}
override suspend fun getWalletName(walletId: String): String? = withContext(dispatchers.io) {
tangemTechApi.getWalletById(walletId).getOrThrow().name
}
override suspend fun sendPushToken(appId: String, pushToken: String) {
withContext(dispatchers.io) {
tangemTechApi.updatePushTokenForApplicationId(
appId,
NotificationApplicationCreateBody(
pushToken = pushToken,
),
).getOrThrow()
}
}
override suspend fun getEligibleNetworks(): List<NotificationsEligibleNetwork> = withContext(dispatchers.io) {
tangemTechApi.getEligibleNetworksForPushNotifications().getOrThrow().map {
NotificationsEligibleNetworkConverter.convert(it)
}
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.data.notifications.converters
import com.tangem.datasource.api.tangemTech.models.CryptoNetworkResponse
import com.tangem.domain.notifications.models.NotificationsEligibleNetwork
import com.tangem.utils.converter.Converter
internal object NotificationsEligibleNetworkConverter : Converter<CryptoNetworkResponse, NotificationsEligibleNetwork> {
override fun convert(value: CryptoNetworkResponse): NotificationsEligibleNetwork {
return NotificationsEligibleNetwork(
id = value.id.toString(),
networkId = value.networkId,
name = value.name,
)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.notifications.di
import com.tangem.data.notifications.DefaultNotificationsRepository
import com.tangem.domain.notifications.NotificationsRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface NotificationsModule {
@Binds
@Singleton
fun bindNotificationsRepository(repository: DefaultNotificationsRepository): NotificationsRepository
}

View file

@ -0,0 +1,256 @@
package com.tangem.data.notifications
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.utils.info.AppInfoProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.data.notifications.converters.NotificationsEligibleNetworkConverter
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.models.*
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultNotificationsRepositoryTest {
private val tangemTechApi: TangemTechApi = mockk()
private val appInfoProvider: AppInfoProvider = mockk()
private val preferencesDataStore: DataStore<Preferences> = mockk()
private val appPreferencesStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = preferencesDataStore,
)
private val repository = DefaultNotificationsRepository(
tangemTechApi = tangemTechApi,
appInfoProvider = appInfoProvider,
appPreferencesStore = appPreferencesStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `GIVEN valid push token WHEN createApplicationId THEN returns application id`() = runTest {
// GIVEN
val pushToken = "test-push-token"
val expectedAppId = "test-app-id"
val expectedAppIdResponse = NotificationApplicationIdResponse(
appId = expectedAppId,
)
coEvery { appInfoProvider.platform } returns "android"
coEvery { appInfoProvider.device } returns "test-device"
coEvery { appInfoProvider.osVersion } returns "11"
coEvery { appInfoProvider.language } returns "en"
coEvery { appInfoProvider.timezone } returns "UTC"
coEvery { tangemTechApi.createApplicationId(any()) } returns ApiResponse.Success(
expectedAppIdResponse,
)
// WHEN
val result = repository.createApplicationId(pushToken)
// THEN
assertThat(result).isEqualTo(expectedAppId)
coVerify {
tangemTechApi.createApplicationId(
NotificationApplicationCreateBody(
platform = "android",
device = "test-device",
systemVersion = "11",
language = "en",
timezone = "UTC",
pushToken = pushToken,
),
)
}
}
@Test
fun `GIVEN application id WHEN saveApplicationId THEN stores it in preferences`() = runTest {
// GIVEN
val appId = "test-app-id"
val preferences = mockk<Preferences>(relaxed = true)
coEvery { preferencesDataStore.updateData(any()) } returns preferences
// WHEN
repository.saveApplicationId(appId)
// THEN
coVerify { preferencesDataStore.updateData(any()) }
}
@Test
fun `GIVEN stored application id WHEN getApplicationId THEN returns it`() = runTest {
// GIVEN
val expectedAppId = "test-app-id"
val preferences = mockk<Preferences>(relaxed = true)
val key = stringPreferencesKey(PreferencesKeys.NOTIFICATIONS_APPLICATION_ID_KEY.name)
every { preferences[key] } returns expectedAppId
coEvery { preferencesDataStore.data } returns flowOf(preferences)
// WHEN
val result = repository.getApplicationId()
// THEN
assertThat(result).isEqualTo(expectedAppId)
}
@Test
fun `GIVEN wallet id and enabled status WHEN setNotificationsEnabledForWallet THEN updates notification status`() =
runTest {
// GIVEN
val walletId = "test-wallet-id"
val enabled = true
coEvery {
tangemTechApi.setNotificationsEnabled(
walletId,
WalletBody(notifyStatus = enabled),
)
} returns ApiResponse.Success(Unit)
// WHEN
repository.setNotificationsEnabledForWallet(walletId, enabled)
// THEN
coVerify { tangemTechApi.setNotificationsEnabled(walletId, WalletBody(notifyStatus = enabled)) }
}
@Test
fun `GIVEN application id and wallet list WHEN associateApplicationIdWithWallets THEN associates them`() = runTest {
// GIVEN
val appId = "test-app-id"
val wallets = listOf("wallet1", "wallet2")
coEvery {
tangemTechApi.associateApplicationIdWithWallets(
appId,
wallets.map { WalletIdBody(it) },
)
} returns ApiResponse.Success(Unit)
// WHEN
repository.associateApplicationIdWithWallets(appId, wallets)
// THEN
coVerify { tangemTechApi.associateApplicationIdWithWallets(appId, wallets.map { WalletIdBody(it) }) }
}
@Test
fun `GIVEN wallet id WHEN isNotificationsEnabledForWallet THEN returns notification status`() = runTest {
// GIVEN
val walletId = "test-wallet-id"
val expectedStatus = true
coEvery { tangemTechApi.getWalletById(walletId) } returns ApiResponse.Success(
WalletResponse(
notifyStatus = expectedStatus,
name = "Test Wallet",
id = walletId,
),
)
// WHEN
val result = repository.isNotificationsEnabledForWallet(walletId)
// THEN
assertThat(result).isEqualTo(expectedStatus)
}
@Test
fun `GIVEN wallet id and name WHEN setWalletName THEN updates wallet name`() = runTest {
// GIVEN
val walletId = "test-wallet-id"
val walletName = "Test Wallet"
coEvery {
tangemTechApi.updateWallet(
walletId,
WalletBody(name = walletName),
)
} returns ApiResponse.Success(Unit)
// WHEN
repository.setWalletName(walletId, walletName)
// THEN
coVerify { tangemTechApi.updateWallet(walletId, WalletBody(name = walletName)) }
}
@Test
fun `GIVEN wallet id WHEN getWalletName THEN returns wallet name`() = runTest {
// GIVEN
val walletId = "test-wallet-id"
val expectedName = "Test Wallet"
coEvery { tangemTechApi.getWalletById(walletId) } returns ApiResponse.Success(
WalletResponse(
notifyStatus = false,
name = expectedName,
id = walletId,
),
)
// WHEN
val result = repository.getWalletName(walletId)
// THEN
assertThat(result).isEqualTo(expectedName)
}
@Test
fun `GIVEN application id and push token WHEN sendPushToken THEN updates push token`() = runTest {
// GIVEN
val appId = "test-app-id"
val pushToken = "test-push-token"
coEvery {
tangemTechApi.updatePushTokenForApplicationId(
appId,
NotificationApplicationCreateBody(pushToken = pushToken),
)
} returns ApiResponse.Success(appId)
// WHEN
repository.sendPushToken(appId, pushToken)
// THEN
coVerify {
tangemTechApi.updatePushTokenForApplicationId(
appId,
NotificationApplicationCreateBody(pushToken = pushToken),
)
}
}
@Test
fun `GIVEN eligible networks WHEN getEligibleNetworks THEN returns converted networks`() = runTest {
// GIVEN
val expectedNetworks = listOf(
CryptoNetworkResponse(
id = 1,
name = "Ethereum",
networkId = "Ethereum",
),
CryptoNetworkResponse(
id = 2,
name = "Bitcoin",
networkId = "bitcoin",
),
)
coEvery { tangemTechApi.getEligibleNetworksForPushNotifications() } returns ApiResponse.Success(
expectedNetworks,
)
// WHEN
val result = repository.getEligibleNetworks()
// THEN
assertThat(result).hasSize(2)
assertThat(result[0]).isEqualTo(NotificationsEligibleNetworkConverter.convert(expectedNetworks[0]))
assertThat(result[1]).isEqualTo(NotificationsEligibleNetworkConverter.convert(expectedNetworks[1]))
coVerify { tangemTechApi.getEligibleNetworksForPushNotifications() }
}
}

1
domain/notifications/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,12 @@
plugins {
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
dependencies {
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(projects.domain.notifications.models)
}

View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,5 @@
plugins {
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}

View file

@ -0,0 +1,7 @@
package com.tangem.domain.notifications.models
data class NotificationsEligibleNetwork(
val id: String,
val networkId: String,
val name: String,
)

View file

@ -0,0 +1,34 @@
package com.tangem.domain.notifications
import com.tangem.domain.notifications.models.NotificationsEligibleNetwork
interface NotificationsRepository {
@Throws
suspend fun createApplicationId(pushToken: String? = null): String
suspend fun saveApplicationId(appId: String)
suspend fun getApplicationId(): String?
@Throws
suspend fun setNotificationsEnabledForWallet(walletId: String, enabled: Boolean)
@Throws
suspend fun associateApplicationIdWithWallets(appId: String, wallets: List<String>)
@Throws
suspend fun isNotificationsEnabledForWallet(walletId: String): Boolean
@Throws
suspend fun setWalletName(walletId: String, walletName: String)
@Throws
suspend fun getWalletName(walletId: String): String?
@Throws
suspend fun sendPushToken(appId: String, pushToken: String)
@Throws
suspend fun getEligibleNetworks(): List<NotificationsEligibleNetwork>
}

View file

@ -291,6 +291,8 @@ include(":domain:networks")
include(":domain:quotes") include(":domain:quotes")
include(":domain:blockaid") include(":domain:blockaid")
include(":domain:blockaid:models") include(":domain:blockaid:models")
include(":domain:notifications")
include(":domain:notifications:models")
// endregion Domain modules // endregion Domain modules
// region Data modules // region Data modules
@ -318,5 +320,6 @@ include(":data:networks")
include(":data:nft") include(":data:nft")
include(":data:onramp") include(":data:onramp")
include(":data:quotes") include(":data:quotes")
include(":data:notifications")
include(":data:blockaid") include(":data:blockaid")
// endregion Data modules // endregion Data modules