Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-20 12:19:43 +03:00
parent 4578bb824e
commit 0979d1b71b
22 changed files with 565 additions and 0 deletions

View file

@ -0,0 +1,39 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.data.pushnotificationpreferences"
}
dependencies {
/** Domain */
implementation(projects.domain.pushNotificationPreferences)
implementation(projects.domain.models)
/** Core */
implementation(projects.core.datasource)
implementation(projects.core.utils)
/** Other */
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
/** 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)
testImplementation(deps.test.turbine)
testImplementation(deps.moshi)
testImplementation(deps.moshi.kotlin)
}

View file

@ -0,0 +1,110 @@
package com.tangem.data.pushnotificationpreferences
import arrow.core.Either
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
/**
* In-memory cache implementation of [WalletPushNotificationPreferencesRepository].
*
* Mock-mode (current): defaults are computed locally and writes are kept in-memory only.
* Real-mode (when Variant C BE is ready): replace TODO blocks with [TangemTechApi] calls.
*
* Defaults for existing users (until BE migration runs): TX read from
* [PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY] (default true), Offers&Updates = true, Price Alerts = false,
* isVisible = true for all three.
*/
internal class DefaultWalletPushNotificationPreferencesRepository(
private val appPreferencesStore: AppPreferencesStore,
@Suppress("unused") private val tangemTechApi: TangemTechApi,
private val cache: RuntimeSharedStore<Map<String, WalletPushNotificationPreferences>>,
private val dispatchers: CoroutineDispatcherProvider,
) : WalletPushNotificationPreferencesRepository {
override suspend fun preload(userWalletId: UserWalletId) {
if (cache.getSyncOrNull()?.containsKey(userWalletId.stringValue) == true) return
val preferences = withContext(dispatchers.io) {
// TODO: uncomment when api is ready
// val response = tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue).getOrThrow()
// PushNotificationPreferencesConverter.convert(response)
loadDefaults(userWalletId)
}
cache.update(default = emptyMap()) { current ->
if (current.containsKey(userWalletId.stringValue)) {
current
} else {
current + (userWalletId.stringValue to preferences)
}
}
}
override fun observePreferences(userWalletId: UserWalletId): Flow<WalletPushNotificationPreferences> = cache.get()
.onStart { preload(userWalletId) }
.map { it[userWalletId.stringValue] }
.filterNotNull()
.distinctUntilChanged()
override suspend fun updatePreference(
userWalletId: UserWalletId,
category: PushNotificationCategory,
isEnabled: Boolean,
): Either<Throwable, Unit> = Either.catch {
val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId)
val updated = applyCategory(current, category, isEnabled)
withContext(dispatchers.io) {
// TODO: uncomment when api is ready
// tangemTechApi.updatePushNotificationPreferences(
// walletId = userWalletId.stringValue,
// body = PushNotificationPreferencesBody(
// areTransactionAlertsEnabled = updated.transactionAlerts.isEnabled,
// areOffersUpdatesEnabled = updated.offersUpdates.isEnabled,
// arePriceAlertsEnabled = updated.priceAlerts.isEnabled,
// ),
// ).getOrThrow()
}
cache.update(default = emptyMap()) { it + (userWalletId.stringValue to updated) }
}
private fun applyCategory(
current: WalletPushNotificationPreferences,
category: PushNotificationCategory,
isEnabled: Boolean,
): WalletPushNotificationPreferences = when (category) {
PushNotificationCategory.TransactionAlerts -> current.copy(
transactionAlerts = current.transactionAlerts.copy(isEnabled = isEnabled),
)
PushNotificationCategory.OffersUpdates -> current.copy(
offersUpdates = current.offersUpdates.copy(isEnabled = isEnabled),
)
PushNotificationCategory.PriceAlerts -> current.copy(
priceAlerts = current.priceAlerts.copy(isEnabled = isEnabled),
)
}
// TODO remove when api is ready, use api methods to load real settings
private suspend fun loadDefaults(userWalletId: UserWalletId): WalletPushNotificationPreferences {
val areTransactionAlertsEnabled = appPreferencesStore
.getObjectMapSync<Boolean>(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY)[userWalletId.stringValue] !=
false
return WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = areTransactionAlertsEnabled, isVisible = true),
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.data.pushnotificationpreferences.converters
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferenceState
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.utils.converter.Converter
internal object PushNotificationPreferencesConverter :
Converter<PushNotificationPreferencesResponse, WalletPushNotificationPreferences> {
override fun convert(value: PushNotificationPreferencesResponse): WalletPushNotificationPreferences =
WalletPushNotificationPreferences(
transactionAlerts = value.transactionAlerts.toDomain(),
offersUpdates = value.offersUpdates.toDomain(),
priceAlerts = value.priceAlerts.toDomain(),
)
private fun PushNotificationPreferenceState.toDomain(): PushNotificationPreference =
PushNotificationPreference(isEnabled = isEnabled, isVisible = isVisible)
}

View file

@ -0,0 +1,31 @@
package com.tangem.data.pushnotificationpreferences.di
import com.tangem.data.pushnotificationpreferences.DefaultWalletPushNotificationPreferencesRepository
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
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 PushNotificationPreferencesModule {
@Singleton
@Provides
fun providesWalletPushNotificationPreferencesRepository(
appPreferencesStore: AppPreferencesStore,
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): WalletPushNotificationPreferencesRepository = DefaultWalletPushNotificationPreferencesRepository(
appPreferencesStore = appPreferencesStore,
tangemTechApi = tangemTechApi,
cache = RuntimeSharedStore(),
dispatchers = dispatchers,
)
}

View file

@ -0,0 +1,141 @@
package com.tangem.data.pushnotificationpreferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import app.cash.turbine.test
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultWalletPushNotificationPreferencesRepositoryTest {
private val tangemTechApi: TangemTechApi = mockk()
private val preferencesDataStore: DataStore<Preferences> = mockk()
private val appPreferencesStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = preferencesDataStore,
)
private val userWalletId = UserWalletId(stringValue = "0011223344556677")
private val otherWalletId = UserWalletId(stringValue = "ffeeddccbbaa9988")
private val repository = DefaultWalletPushNotificationPreferencesRepository(
appPreferencesStore = appPreferencesStore,
tangemTechApi = tangemTechApi,
cache = RuntimeSharedStore(),
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `GIVEN no prior state WHEN preload THEN cache contains defaults`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.preload(userWalletId)
repository.observePreferences(userWalletId).test {
assertThat(awaitItem()).isEqualTo(defaults(transactionAlertsEnabled = true))
}
}
@Test
fun `GIVEN preload already done WHEN preload called again THEN no-op`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.preload(userWalletId)
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
repository.preload(userWalletId)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.offersUpdates.isEnabled).isFalse()
}
}
@Test
fun `GIVEN cache miss WHEN updatePreference THEN loads defaults and applies update`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
val result = repository.updatePreference(
userWalletId = userWalletId,
category = PushNotificationCategory.PriceAlerts,
isEnabled = true,
)
assertThat(result).isInstanceOf(Either.Right::class.java)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.priceAlerts.isEnabled).isTrue()
assertThat(item.offersUpdates.isEnabled).isTrue()
assertThat(item.transactionAlerts.isEnabled).isTrue()
}
}
@Test
fun `GIVEN preloaded state WHEN updatePreference for each category THEN updates only that category`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.preload(userWalletId)
repository.updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, isEnabled = false)
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
repository.updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.transactionAlerts.isEnabled).isFalse()
assertThat(item.offersUpdates.isEnabled).isFalse()
assertThat(item.priceAlerts.isEnabled).isTrue()
}
}
@Test
fun `GIVEN no subscription yet WHEN observePreferences subscribed THEN triggers preload and emits defaults`() =
runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item).isEqualTo(defaults(transactionAlertsEnabled = true))
}
}
@Test
fun `GIVEN updates for different wallets WHEN observed independently THEN each wallet has its own state`() =
runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
repository.updatePreference(otherWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.offersUpdates.isEnabled).isFalse()
assertThat(item.priceAlerts.isEnabled).isFalse()
}
repository.observePreferences(otherWalletId).test {
val item = awaitItem()
assertThat(item.offersUpdates.isEnabled).isTrue()
assertThat(item.priceAlerts.isEnabled).isTrue()
}
}
private fun defaults(transactionAlertsEnabled: Boolean) = WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = transactionAlertsEnabled, isVisible = true),
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
)
}