Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-01 13:58:38 +03:00
commit 9ba66ba084
179 changed files with 5757 additions and 1156 deletions

4
.gitignore vendored
View file

@ -9,6 +9,10 @@ local.properties
# Google services
# Huawei services
app/agconnect-services.json
app/src/debug/google-services.json
app/src/internal/google-services.json
app/src/external/google-services.json

View file

@ -11,9 +11,14 @@ plugins {
alias(deps.plugins.firebase.crashlytics)
alias(deps.plugins.firebase.perf)
alias(deps.plugins.ksp)
id(deps.plugins.agconnect.get().pluginId)
id("configuration")
}
agcp {
manifest = false
}
android {
namespace = "com.tangem.wallet"
testOptions {
@ -53,12 +58,35 @@ android {
keyPassword = keystoreProperties["key_password"] as String
}
}
flavorDimensions += "services"
productFlavors {
create("google") {
dimension = "services"
buildConfigField("String", "FLAVOR_NAME", "\"google\"")
}
create("huawei") {
dimension = "services"
buildConfigField("String", "FLAVOR_NAME", "\"huawei\"")
}
}
buildTypes {
debug {
buildConfigField("String", "BUILD_TYPE", "\"debug\"")
}
release {
buildConfigField("String", "BUILD_TYPE", "\"release\"")
}
}
}
configurations.all {
exclude(group = "org.bouncycastle", module = "bcprov-jdk15to18")
exclude(group = "com.github.komputing.kethereum")
exclude(group = "com.android.tools.build", module = "gradle")
resolutionStrategy {
dependencySubstitution {
@ -379,4 +407,9 @@ dependencies {
// excludes version 9999.0-empty-to-avoid-conflict-with-guava
exclude(group = "com.google.guava", module = "listenablefuture")
}
/** Huawei flavor-specific dependencies */
"huaweiImplementation"(deps.huawei.push)
"huaweiImplementation"(deps.agconnect.agcp)
"huaweiImplementation"(deps.agconnect.core)
}

View file

@ -7,6 +7,13 @@
-keep class com.google.android.gms.internal.** { *; }
-keepclasseswithmembers class com.google.firebase.FirebaseException
# huawei push kit
-ignorewarnings
-keepattributes SourceFile,LineNumberTable
-keep class com.huawei.hianalytics.**{*;}
-keep class com.huawei.updatesdk.**{*;}
-keep class com.huawei.hms.**{*;}
# hedera sdk
-keep class com.hedera.hashgraph.sdk.** { *; }
-keep interface com.hedera.hashgraph.sdk.** { *; }

View file

@ -14,6 +14,8 @@ import com.kaspersky.kaspresso.kaspresso.Kaspresso
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import com.tangem.common.allure.FailedStepScreenshotInterceptor
import com.tangem.common.rules.ApiEnvironmentRule
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
@ -46,6 +48,9 @@ abstract class BaseTestCase : TestCase(
@Inject
lateinit var appPreferencesStore: AppPreferencesStore
@Inject
lateinit var featureTogglesManager: FeatureTogglesManager
private val hiltRule = HiltAndroidRule(this)
private val apiEnvironmentRule = ApiEnvironmentRule()
private val permissionRule = GrantPermissionRule.grant(
@ -90,6 +95,7 @@ abstract class BaseTestCase : TestCase(
apiEnvironmentRule.setup(apiConfigsManager)
ActivityScenario.launch(MainActivity::class.java)
Intents.init()
setFeatureToggles()
additionalBeforeSection()
}.after {
additionalAfterSection()
@ -113,4 +119,14 @@ abstract class BaseTestCase : TestCase(
{
composeTestRule.onRoot(useUnmergedTree = useUnmergedTree).printToLog(tag, maxDepth)
}
private fun setFeatureToggles() {
runBlocking {
with(featureTogglesManager as MutableFeatureTogglesManager) {
changeToggle("WALLET_CONNECT_REDESIGN_ENABLED", true)
changeToggle("WALLET_BALANCE_FETCHER_ENABLED", true)
}
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.tap.data
package com.tangem.tap
import com.google.firebase.messaging.FirebaseMessaging
import com.tangem.utils.notifications.PushNotificationsTokenProvider

View file

@ -1,6 +1,6 @@
package com.tangem.tap.di.data
package com.tangem.tap.di
import com.tangem.tap.data.FirebasePushNotificationsTokenProvider
import com.tangem.tap.FirebasePushNotificationsTokenProvider
import com.tangem.utils.notifications.PushNotificationsTokenProvider
import dagger.Binds
import dagger.Module
@ -10,7 +10,7 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface PushNotificationsModule {
internal interface GooglePushModule {
@Binds
@Singleton

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<service
android:name="com.tangem.tap.HuaweiPushService"
android:exported="false">
<intent-filter>
<action android:name="com.huawei.push.action.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data
android:name="push_kit_auto_init_enabled"
android:value="true" />
</application>
</manifest>

View file

@ -0,0 +1,50 @@
package com.tangem.tap
import android.content.Context
import com.google.firebase.messaging.FirebaseMessaging
import com.huawei.agconnect.AGConnectOptionsBuilder
import com.huawei.hms.aaid.HmsInstanceId
import com.huawei.hms.common.ApiException
import com.tangem.google.GoogleServicesHelper
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.notifications.PushNotificationsTokenProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
internal class HuaweiPushNotificationsTokenProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val coroutineDispatcherProvider: CoroutineDispatcherProvider,
) : PushNotificationsTokenProvider {
override suspend fun getToken(): String {
val isGoogleServicesAvailable = GoogleServicesHelper.checkGoogleServicesAvailability(context)
return if (isGoogleServicesAvailable) {
try {
FirebaseMessaging.getInstance().token.await()
} catch (ex: Exception) {
Timber.e(ex)
""
}
} else {
withContext(coroutineDispatcherProvider.io) {
try {
val appId = AGConnectOptionsBuilder().build(context).getString(APP_ID_KEY)
val token = HmsInstanceId.getInstance(context).getToken(appId, TOKEN_REQUEST_MODE)
Timber.i("Requested token from HuaweiService: $token")
token
} catch (e: ApiException) {
Timber.i("Fetching token from HuaweiService failed cause: ${e.message}")
""
}
}
}
}
companion object {
private const val APP_ID_KEY = "client/app_id"
private const val TOKEN_REQUEST_MODE = "HCM"
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.tap
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.PushNotificationDelegate
import timber.log.Timber
class HuaweiPushService : HmsMessageService() {
private val pushNotificationDelegate: PushNotificationDelegate by lazy {
PushNotificationDelegate(applicationContext)
}
override fun onNewToken(token: String?, bundle: Bundle?) {
super.onNewToken(token, bundle)
Timber.i("HuaweiPushService: On new token from HuaweiService: $token")
}
override fun onTokenError(e: Exception?, bundle: Bundle?) {
super.onTokenError(e, bundle)
Timber.i("HuaweiPushService: Fetching token from HuaweiService failed cause: ${e?.message}")
}
override fun onMessageReceived(message: RemoteMessage?) {
super.onMessageReceived(message)
val isGoogleServicesAvailable = GoogleServicesHelper.checkGoogleServicesAvailability(this)
if (isGoogleServicesAvailable) return
val notification = message?.notification ?: return
val channelId = notification.channelId ?: TANGEM_CHANNEL_ID
pushNotificationDelegate.showNotification(
dataMap = message.dataOfMap,
title = notification.title,
body = notification.body,
channelId = channelId,
priority = message.urgency,
imageUrl = notification.imageUrl,
vibratePattern = notification.vibrateConfig,
)
}
private companion object {
const val TANGEM_CHANNEL_ID = "Tangem General" // General channel for notifications
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.tap.di
import com.tangem.tap.HuaweiPushNotificationsTokenProvider
import com.tangem.utils.notifications.PushNotificationsTokenProvider
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 HuaweiPushModule {
@Binds
@Singleton
fun bindPushNotificationsTokenProvider(impl: HuaweiPushNotificationsTokenProvider): PushNotificationsTokenProvider
}

View file

@ -77,6 +77,7 @@ import com.tangem.wallet.BuildConfig
import dagger.hilt.EntryPoints
import kotlinx.coroutines.*
import org.rekotlin.Store
import timber.log.Timber
import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository
lateinit var store: Store<AppState>
@ -289,13 +290,17 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
tangemAppLoggerInitializer.initialize()
Timber.i("APP STARTED")
if (BuildConfig.TESTER_MENU_ENABLED) {
Timber.i(featureTogglesManager.toString())
}
foregroundActivityObserver = ForegroundActivityObserver()
registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks)
// We need to initialize the toggles and excludedBlockchainsManager before the MainActivity starts using them.
runBlocking {
awaitAll(
async { featureTogglesManager.init() },
async { excludedBlockchainsManager.init() },
)
initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize())

View file

@ -0,0 +1,13 @@
package com.tangem.tap.common.buildconfig
import com.tangem.utils.buildConfig.AppConfigurationProvider
import com.tangem.wallet.BuildConfig
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class AppConfigurationProviderImpl @Inject constructor() : AppConfigurationProvider {
override fun isDebug(): Boolean = BuildConfig.BUILD_TYPE == "debug"
override fun isHuawei(): Boolean = BuildConfig.FLAVOR_NAME == "huawei"
}

View file

@ -0,0 +1,102 @@
package com.tangem.tap.common.pushes
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import coil.executeBlocking
import coil.request.ImageRequest
import com.tangem.domain.common.LogConfig
import com.tangem.tap.MainActivity
import com.tangem.tap.common.images.createCoilImageLoader
import com.tangem.tap.features.intentHandler.handlers.OnPushClickedIntentHandler
import com.tangem.wallet.R
class PushNotificationDelegate(private val context: Context) {
@Suppress("LongParameterList")
fun showNotification(
dataMap: Map<String, String>,
title: String?,
body: String?,
channelId: String,
priority: Int,
imageUrl: Uri? = null,
vibratePattern: LongArray?,
) {
val intent = Intent(context, MainActivity::class.java).apply {
dataMap.forEach { (key, value) ->
putExtra(key, value)
}
putExtra(OnPushClickedIntentHandler.OPENED_FROM_GCM_PUSH, true)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val pendingIntent = PendingIntent.getActivity(
/* context = */ context,
/* requestCode = */ PUSH_NOTIFICATION_REQUEST_CODE,
/* intent = */ intent,
/* flags = */ PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE,
)
val notificationBuilder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_tangem_24)
.setContentTitle(title)
.setContentText(body)
.setPriority(priority)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setVibrate(vibratePattern)
.apply {
imageUrl?.let { uri ->
val bitmap = getBitmapImageFromUrl(uri)
setStyle(
NotificationCompat
.BigPictureStyle()
.bigPicture(bitmap),
).setLargeIcon(bitmap)
}
}
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel = NotificationChannel(
channelId,
ContextCompat.getString(context, R.string.tangem_app_name),
NotificationManager.IMPORTANCE_HIGH,
)
notificationManager.createNotificationChannel(notificationChannel)
}
// Generating unique notification id
val uniqueId = (System.currentTimeMillis() % Integer.MAX_VALUE).toInt()
notificationManager.notify(
/* id = */ uniqueId,
/* notification = */ notificationBuilder.build(),
)
}
private fun getBitmapImageFromUrl(url: Uri): Bitmap? {
return createCoilImageLoader(
context,
logEnabled = LogConfig.imageLoader,
).executeBlocking(
ImageRequest.Builder(context)
.data(url)
.build(),
).drawable?.toBitmap()
}
private companion object {
const val PUSH_NOTIFICATION_REQUEST_CODE = 123
}
}

View file

@ -1,30 +1,17 @@
package com.tangem.tap.common.pushes
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import coil.executeBlocking
import coil.request.ImageRequest
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.tangem.domain.common.LogConfig
import com.tangem.tap.MainActivity
import com.tangem.tap.common.images.createCoilImageLoader
import com.tangem.tap.features.intentHandler.handlers.OnPushClickedIntentHandler
import com.tangem.wallet.R
import timber.log.Timber
@SuppressLint("MissingFirebaseInstanceTokenRefresh")
internal class TangemPushNotificationService : FirebaseMessagingService() {
private val pushNotificationDelegate: PushNotificationDelegate by lazy {
PushNotificationDelegate(applicationContext)
}
override fun onNewToken(token: String) {
super.onNewToken(token)
Timber.d("New FCM token received: $token")
@ -36,73 +23,18 @@ internal class TangemPushNotificationService : FirebaseMessagingService() {
val notification = message.notification ?: return
val channelId = notification.channelId ?: TANGEM_CHANNEL_ID
val intent = Intent(applicationContext, MainActivity::class.java).apply {
message.data.forEach {
putExtra(it.key, it.value)
}
putExtra(OnPushClickedIntentHandler.OPENED_FROM_GCM_PUSH, true)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val pendingIntent = PendingIntent.getActivity(
/* context = */ this,
/* requestCode = */ PUSH_NOTIFICATION_REQUEST_CODE,
/* intent = */ intent,
/* flags = */ PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE,
pushNotificationDelegate.showNotification(
dataMap = message.data,
title = notification.title,
body = notification.body,
channelId = channelId,
priority = message.priority,
imageUrl = notification.imageUrl,
vibratePattern = notification.vibrateTimings,
)
val notificationBuilder =
NotificationCompat.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.ic_tangem_24)
.setContentTitle(notification.title)
.setContentText(notification.body)
.setPriority(message.priority)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setVibrate(notification.vibrateTimings)
.apply {
notification.imageUrl?.let { uri ->
val bitmap = getBitmapImageFromUrl(uri)
setStyle(
NotificationCompat
.BigPictureStyle()
.bigPicture(bitmap),
).setLargeIcon(bitmap)
}
}
val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel = NotificationChannel(
channelId,
ContextCompat.getString(applicationContext, R.string.tangem_app_name),
NotificationManager.IMPORTANCE_HIGH,
)
notificationManager.createNotificationChannel(notificationChannel)
}
// Generating unique notification id
val uniqueId = (System.currentTimeMillis() % Integer.MAX_VALUE).toInt()
notificationManager.notify(
/* id = */ uniqueId,
/* notification = */ notificationBuilder.build(),
)
}
private fun getBitmapImageFromUrl(url: Uri): Bitmap? {
return createCoilImageLoader(
applicationContext,
logEnabled = LogConfig.imageLoader,
).executeBlocking(
ImageRequest.Builder(applicationContext)
.data(url)
.build(),
).drawable?.toBitmap()
}
private companion object {
const val TANGEM_CHANNEL_ID = "Tangem General" // General channel for notifications
const val PUSH_NOTIFICATION_REQUEST_CODE = 123
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.tap.di
import com.tangem.tap.common.buildconfig.AppConfigurationProviderImpl
import com.tangem.utils.buildConfig.AppConfigurationProvider
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 AppConfigurationModule {
@Binds
@Singleton
fun bindAppConfigurationProvider(impl: AppConfigurationProviderImpl): AppConfigurationProvider
}

View file

@ -25,7 +25,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Suppress("TooManyFunctions")
@Suppress("TooManyFunctions", "LargeClass")
@Module
@InstallIn(SingletonComponent::class)
internal object WalletsDomainModule {
@ -352,4 +352,24 @@ internal object WalletsDomainModule {
walletsRepository = walletsRepository,
)
}
@Provides
@Singleton
fun providesIsUpgradeWalletNotificationEnabledUseCase(
walletsRepository: WalletsRepository,
): IsUpgradeWalletNotificationEnabledUseCase {
return IsUpgradeWalletNotificationEnabledUseCase(
walletsRepository = walletsRepository,
)
}
@Provides
@Singleton
fun providesDismissUpgradeWalletNotificationUseCase(
walletsRepository: WalletsRepository,
): DismissUpgradeWalletNotificationUseCase {
return DismissUpgradeWalletNotificationUseCase(
walletsRepository = walletsRepository,
)
}
}

View file

@ -1,5 +1,7 @@
package com.tangem.tap.di.hot
import com.tangem.data.wallets.hot.DefaultHotWalletAccessor
import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.tap.features.hot.TangemHotSDKProxy
import dagger.Binds
@ -15,4 +17,8 @@ internal interface TangemHotSdkModule {
@Binds
@Singleton
fun bindTangemHotSdk(proxy: TangemHotSDKProxy): TangemHotSdk
@Binds
@Singleton
fun bindHotWalletAccessor(default: DefaultHotWalletAccessor): HotWalletAccessor
}

View file

@ -17,6 +17,7 @@ import com.tangem.features.disclaimer.api.components.DisclaimerComponent
import com.tangem.features.home.api.HomeComponent
import com.tangem.features.hotwallet.AddExistingWalletComponent
import com.tangem.features.hotwallet.CreateMobileWalletComponent
import com.tangem.features.hotwallet.UpgradeWalletComponent
import com.tangem.features.hotwallet.WalletActivationComponent
import com.tangem.features.hotwallet.CreateWalletBackupComponent
import com.tangem.features.hotwallet.UpdateAccessCodeComponent
@ -103,6 +104,7 @@ internal class ChildFactory @Inject constructor(
private val chooseManagedTokensComponentFactory: ChooseManagedTokensComponent.Factory,
private val createWalletSelectionComponentFactory: CreateWalletSelectionComponent.Factory,
private val createMobileWalletComponentFactory: CreateMobileWalletComponent.Factory,
private val upgradeWalletComponentFactory: UpgradeWalletComponent.Factory,
private val addExistingWalletComponentFactory: AddExistingWalletComponent.Factory,
private val walletActivationComponentFactory: WalletActivationComponent.Factory,
private val createWalletBackupComponentFactory: CreateWalletBackupComponent.Factory,
@ -486,6 +488,15 @@ internal class ChildFactory @Inject constructor(
componentFactory = createMobileWalletComponentFactory,
)
}
is AppRoute.UpgradeWallet -> {
createComponentChild(
context = context,
params = UpgradeWalletComponent.Params(
userWalletId = route.userWalletId,
),
componentFactory = upgradeWalletComponentFactory,
)
}
is AppRoute.AddExistingWallet -> {
createComponentChild(
context = context,

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/splash_logo"
android:insetLeft="48dp"
android:insetRight="48dp"
android:insetTop="48dp"
android:insetBottom="48dp"/>
android:insetLeft="55dp"
android:insetRight="55dp"
android:insetTop="55dp"
android:insetBottom="55dp"/>
<!-- image should fit exactly in square 192x192 dp-->
<!-- (192 - splash_logo_side)/2 = 48dp -->
<!-- (192 - splash_logo_side)/2 = 55dp -->

View file

@ -26,6 +26,4 @@
<color name="text_primary_1">#1E1E1E</color>
<color name="text_secondary">#656565</color>
<color name="background_splash">#000000</color>
</resources>

View file

@ -16,7 +16,7 @@
</style>
<style name="SplashTheme" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/background_splash</item>
<item name="windowSplashScreenBackground">@color/background_primary</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/inset_splash</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
</style>

View file

@ -16,6 +16,13 @@ plugins {
alias(deps.plugins.ksp) apply false
}
buildscript {
dependencies {
classpath(deps.gradle.android)
classpath(deps.agconnect.agcp)
}
}
val clean by tasks.registering {
delete(rootProject.buildDir)
}

View file

@ -311,6 +311,11 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
object CreateMobileWallet : AppRoute(path = "/create_mobile_wallet")
@Serializable
data class UpgradeWallet(
val userWalletId: UserWalletId,
) : AppRoute(path = "/upgrade_wallet/${userWalletId.stringValue}")
@Serializable
object AddExistingWallet : AppRoute(path = "/add_existing_wallet")

View file

@ -9,6 +9,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.common.ui.R
import com.tangem.core.ui.extensions.TextReference
@ -73,6 +74,8 @@ private fun Title(title: TextReference) {
text = title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@ -82,6 +85,8 @@ private fun Subtitle(subtitle: TextReference) {
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
text = subtitle.resolveReference(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}

View file

@ -139,33 +139,59 @@ private fun NameAndInfo(
)
}
}
Text(
text = " $DOT ",
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
)
AnimatedContent(
targetState = balance,
label = "Balance content",
) { balance ->
val (balanceValue, isFlickering) = getBalanceValueAndFlickerState(balance)
BalanceContent(balance)
}
}
}
if (balanceValue == null) {
TextShimmer(
style = TangemTheme.typography.caption2,
text = "aaaaa",
)
} else {
@Composable
private fun BalanceContent(balance: UserWalletItemUM.Balance, modifier: Modifier = Modifier) {
AnimatedContent(
modifier = modifier,
targetState = balance,
label = "Balance content",
) { balance ->
when (balance) {
UserWalletItemUM.Balance.Locked -> {
Icon(
modifier = Modifier
.padding(start = 4.dp, bottom = 2.dp, top = 2.dp)
.size(12.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_lock_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
UserWalletItemUM.Balance.NotShowing -> {
/** No balance and no dot */
}
else -> {
Row {
Text(
text = balanceValue,
style = TangemTheme.typography.caption2.applyBladeBrush(
isEnabled = isFlickering,
textColor = TangemTheme.colors.text.tertiary,
),
text = " $DOT ",
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
)
val (balanceValue, isFlickering) = getBalanceValueAndFlickerState(balance)
if (balanceValue == null) {
TextShimmer(
style = TangemTheme.typography.caption2,
text = "aaaaa",
)
} else {
Text(
text = balanceValue,
style = TangemTheme.typography.caption2.applyBladeBrush(
isEnabled = isFlickering,
textColor = TangemTheme.colors.text.tertiary,
),
maxLines = 1,
)
}
}
}
}
@ -246,9 +272,8 @@ fun getBalanceValueAndFlickerState(balance: UserWalletItemUM.Balance): Pair<Stri
return when (balance) {
is UserWalletItemUM.Balance.Failed -> DASH_SIGN to false
is UserWalletItemUM.Balance.Hidden -> THREE_STARS to false
is UserWalletItemUM.Balance.Loading -> null to false
is UserWalletItemUM.Balance.Locked -> stringResourceSafe(R.string.common_locked) to false
is UserWalletItemUM.Balance.Loaded -> balance.value to balance.isFlickering
else -> null to false
}
}
@ -392,6 +417,15 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider<UserWal
isEnabled = true,
onClick = {},
),
UserWalletItemUM(
id = UserWalletId("user_wallet_3".encodeToByteArray()),
name = stringReference("Multi Card"),
information = getInformation(cardCount = 3),
balance = UserWalletItemUM.Balance.NotShowing,
imageState = UserWalletItemUM.ImageState.MobileWallet,
isEnabled = true,
onClick = {},
),
)
private fun getInformation(cardCount: Int): UserWalletItemUM.Information.Loaded {

View file

@ -2,8 +2,8 @@ package com.tangem.common.ui.userwallet.converter
import com.tangem.common.ui.R
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
@ -12,7 +12,6 @@ import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.card.common.util.getCardsCount
import com.tangem.domain.models.ArtworkModel
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.wallet.UserWallet
@ -37,10 +36,10 @@ class UserWalletItemUMConverter(
private val isBalanceHidden: Boolean = false,
private val authMode: Boolean = false,
private val endIcon: UserWalletItemUM.EndIcon = UserWalletItemUM.EndIcon.None,
private val artwork: ArtworkModel? = null,
artwork: UserWalletItemUM.ImageState? = null,
) : Converter<UserWallet, UserWalletItemUM> {
private val artworkUMConverter = ArtworkUMConverter()
private val artwork = artwork ?: UserWalletItemUM.ImageState.Loading
override fun convert(value: UserWallet): UserWalletItemUM {
return with(value) {
@ -52,7 +51,7 @@ class UserWalletItemUMConverter(
isEnabled = isEnabled(userWallet = this),
endIcon = endIcon,
onClick = { onClick(value.walletId) },
imageState = getImageState(userWallet = value),
imageState = artwork,
label = getLabelOrNull(userWallet = this),
)
}
@ -73,14 +72,6 @@ class UserWalletItemUMConverter(
}
}
private fun getImageState(userWallet: UserWallet): UserWalletItemUM.ImageState {
return when {
userWallet is UserWallet.Hot -> UserWalletItemUM.ImageState.MobileWallet
artwork != null -> UserWalletItemUM.ImageState.Image(artworkUMConverter.convert(artwork))
else -> UserWalletItemUM.ImageState.Loading
}
}
private fun getInfo(userWallet: UserWallet): UserWalletItemUM.Information.Loaded {
val text = when (userWallet) {
is UserWallet.Cold -> {
@ -100,8 +91,9 @@ class UserWalletItemUMConverter(
private fun getBalanceInfo(userWallet: UserWallet): UserWalletItemUM.Balance {
return when {
isBalanceHidden -> UserWalletItemUM.Balance.Hidden
userWallet.isLocked -> UserWalletItemUM.Balance.Locked
authMode -> UserWalletItemUM.Balance.NotShowing
isBalanceHidden -> UserWalletItemUM.Balance.Hidden
balance == null -> UserWalletItemUM.Balance.Loading
else -> {
when (balance) {

View file

@ -28,6 +28,8 @@ data class UserWalletItemUM(
data object Hidden : Balance()
data object NotShowing : Balance()
data object Locked : Balance()
data object Failed : Balance()

View file

@ -1,4 +1,6 @@
import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants
import com.squareup.kotlinpoet.*
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import org.json.JSONArray
plugins {
alias(deps.plugins.android.library)
@ -9,8 +11,24 @@ plugins {
id("configuration")
}
buildscript {
dependencies {
classpath("com.squareup:kotlinpoet:1.15.0")
classpath("org.json:json:20231013")
}
}
android {
namespace = "com.tangem.core.configtoggle"
sourceSets["main"].java.srcDir("build/generated/source/toggles")
}
tasks.named("preBuild") {
dependsOn(generateFeatureToggles /*generateExcludedBlockchainToggles*/)
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
@ -32,7 +50,72 @@ dependencies {
implementation(projects.core.utils)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
}
val generateFeatureToggles by tasks.registering {
generateToggles(
inputFilePath = "src/main/assets/configs/feature_toggles_config.json",
generatedFileName = "FeatureToggles",
)
}
val generateExcludedBlockchainToggles by tasks.registering {
generateToggles(
inputFilePath = "src/main/assets/configs/excluded_blockchains_config.json",
generatedFileName = "ExcludedBlockchainToggles",
)
}
fun Task.generateToggles(inputFilePath: String, generatedFileName: String) {
val inputFile = file(inputFilePath)
val outputDir = file("build/generated/source/toggles")
inputs.file(inputFile)
outputs.dir(outputDir)
doLast {
val jsonText = inputFile.readText()
val jsonArray = JSONArray(jsonText)
val entries = (0 until jsonArray.length()).map { i ->
val obj = jsonArray.getJSONObject(i)
val name = obj.getString("name")
val version = obj.getString("version")
CodeBlock.of("%S to %S", name, version)
}
val mapInitializer = CodeBlock.builder()
.add("mapOf(\n")
.indent()
.apply {
entries.forEachIndexed { index, entry ->
add(entry)
if (index != entries.lastIndex) add(",\n") else add("\n")
}
}
.unindent()
.add(")")
.build()
val objectBuilder = TypeSpec.objectBuilder(name = generatedFileName)
.addKdoc("Generated from $inputFilePath")
.addProperty(
PropertySpec.builder("values", MAP.parameterizedBy(STRING, STRING))
.initializer(mapInitializer)
.build()
)
val fileSpec = FileSpec.builder(packageName = "com.tangem.core.configtoggle", fileName = generatedFileName)
.addType(objectBuilder.build())
.build()
val outputPackageDir = File(outputDir, "")
outputPackageDir.mkdirs()
fileSpec.writeTo(outputPackageDir)
}
}

View file

@ -62,5 +62,13 @@
{
"name": "NEW_TOKEN_RECEIVE_ENABLED",
"version": "5.28.0"
},
{
"name": "YIELD_LENDING_FEATURE_ENABLED",
"version": "undefined"
},
{
"name": "NEW_ONRAMP_MAIN_ENABLED",
"version": "undefined"
}
]

View file

@ -5,16 +5,14 @@ import com.tangem.core.configtoggle.BuildConfig
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.storage.LocalTogglesStorage
import com.tangem.core.configtoggle.storage.FeatureTogglesLocalStorage
import com.tangem.core.configtoggle.version.DefaultVersionProvider
import com.tangem.datasource.asset.loader.AssetLoader
import com.tangem.datasource.local.preferences.AppPreferencesStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.runBlocking
import javax.inject.Singleton
@Module
@ -25,29 +23,17 @@ internal object FeatureTogglesManagerModule {
@Singleton
fun provideFeatureTogglesManager(
@ApplicationContext context: Context,
assetLoader: AssetLoader,
appPreferencesStore: AppPreferencesStore,
): FeatureTogglesManager {
val localTogglesStorage = LocalTogglesStorage(assetLoader)
val versionProvider = DefaultVersionProvider(context)
return if (BuildConfig.TESTER_MENU_ENABLED) {
DevFeatureTogglesManager(
localTogglesStorage = localTogglesStorage,
appPreferencesStore = appPreferencesStore,
versionProvider = versionProvider,
featureTogglesLocalStorage = FeatureTogglesLocalStorage(appPreferencesStore),
)
} else {
ProdFeatureTogglesManager(
localTogglesStorage = localTogglesStorage,
versionProvider = versionProvider,
)
}.also {
// We need to initialize during the hilt graph creation
// in order to provide the feature toggles correctly to other dependencies.
runBlocking {
it.init()
}
ProdFeatureTogglesManager(versionProvider = versionProvider)
}
}
}

View file

@ -7,9 +7,6 @@ package com.tangem.core.configtoggle.feature
*/
interface FeatureTogglesManager {
/** Initialize manager */
suspend fun init()
/** Check feature toggle availability by name [name] */
fun isFeatureEnabled(name: String): Boolean
}

View file

@ -1,77 +1,77 @@
package com.tangem.core.configtoggle.feature.impl
import androidx.annotation.VisibleForTesting
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.MutableFeatureTogglesManager
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.storage.FeatureTogglesLocalStorage
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import kotlinx.coroutines.runBlocking
import java.util.Locale
import kotlin.properties.Delegates
/**
* Feature toggles manager implementation in DEV build
* Feature toggles manager implementation in dev or mocked build
*
* @property localTogglesStorage local feature toggles storage
* @property appPreferencesStore application local store
* @property versionProvider application version provider
* @property featureTogglesLocalStorage local storage for feature toggles
*/
internal class DevFeatureTogglesManager(
private val localTogglesStorage: TogglesStorage,
private val appPreferencesStore: AppPreferencesStore,
private val versionProvider: VersionProvider,
private val featureTogglesLocalStorage: FeatureTogglesLocalStorage,
) : MutableFeatureTogglesManager {
private var featureTogglesMap: MutableMap<String, Boolean>? = null
private var localFeatureTogglesMap: Map<String, Boolean>? = null
private var fileFeatureTogglesMap: Map<String, Boolean> = getFileFeatureToggles()
private var featureTogglesMap: MutableMap<String, Boolean> by Delegates.notNull()
override suspend fun init() {
if (featureTogglesMap != null && localFeatureTogglesMap != null) {
return // Already initialized
}
init {
val savedFeatureToggles = runBlocking { featureTogglesLocalStorage.getSyncOrEmpty() }
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
val savedFeatureToggles = appPreferencesStore.getObjectSyncOrNull<Map<String, Boolean>>(
key = PreferencesKeys.FEATURE_TOGGLES_KEY,
) ?: emptyMap<String, Boolean>()
val localFeatureToggles = localTogglesStorage.toggles
.associateToggles(currentVersion = versionProvider.get().orEmpty())
localFeatureTogglesMap = localFeatureToggles
featureTogglesMap = localFeatureToggles
featureTogglesMap = fileFeatureTogglesMap
.mapValues { resultToggle ->
savedFeatureToggles[resultToggle.key] ?: resultToggle.value
}
.toMutableMap()
}
override fun isFeatureEnabled(name: String): Boolean = featureTogglesMap!![name] ?: false
override fun isFeatureEnabled(name: String): Boolean = featureTogglesMap[name] == true
override fun isMatchLocalConfig(): Boolean = featureTogglesMap == localFeatureTogglesMap
override fun getFeatureToggles(): Map<String, Boolean> = featureTogglesMap
override fun getFeatureToggles(): Map<String, Boolean> = featureTogglesMap!!
override fun isMatchLocalConfig(): Boolean = featureTogglesMap == fileFeatureTogglesMap
override suspend fun changeToggle(name: String, isEnabled: Boolean) {
featureTogglesMap!![name] ?: return
featureTogglesMap!![name] = isEnabled
appPreferencesStore.storeFeatureToggles(value = featureTogglesMap!!)
featureTogglesMap[name] ?: return
featureTogglesMap[name] = isEnabled
featureTogglesLocalStorage.store(value = featureTogglesMap)
}
override suspend fun recoverLocalConfig() {
featureTogglesMap = localFeatureTogglesMap!!.toMutableMap()
appPreferencesStore.storeFeatureToggles(value = localFeatureTogglesMap!!)
featureTogglesMap = fileFeatureTogglesMap.toMutableMap()
featureTogglesLocalStorage.store(value = fileFeatureTogglesMap)
}
override fun toString(): String {
return buildString {
append("DevFeatureTogglesManager:\n")
append("|------------------------------------------|-----------|\n")
append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", "name", "isEnabled"))
append("|------------------------------------------|-----------|\n")
featureTogglesMap.entries.forEachIndexed { index, (name, isEnabled) ->
append(String.format(Locale.getDefault(), "| %-40s | %-9s |\n", name, isEnabled))
}
append("|------------------------------------------|-----------|")
}
}
private fun getFileFeatureToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion)
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun setFeatureToggles(map: MutableMap<String, Boolean>) {
featureTogglesMap = map
}
private suspend fun AppPreferencesStore.storeFeatureToggles(value: Map<String, Boolean>) {
storeObject(PreferencesKeys.FEATURE_TOGGLES_KEY, value)
}
}

View file

@ -1,41 +1,30 @@
package com.tangem.core.configtoggle.feature.impl
import androidx.annotation.VisibleForTesting
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.utils.defineTogglesAvailability
import com.tangem.core.configtoggle.version.VersionProvider
/**
* Feature toggles manager implementation in PROD build
*
* @property localTogglesStorage local feature toggles storage
* @property versionProvider application version provider
* @property versionProvider application version provider
*/
internal class ProdFeatureTogglesManager(
private val localTogglesStorage: TogglesStorage,
private val versionProvider: VersionProvider,
) : FeatureTogglesManager {
private var featureToggles: Map<String, Boolean>? = null
private val featureToggles: Map<String, Boolean> = getFileFeatureToggles()
override suspend fun init() {
if (featureToggles != null) {
return // Already initialized
}
override fun isFeatureEnabled(name: String): Boolean = featureToggles[name] == true
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
featureToggles = localTogglesStorage.toggles
.associateToggles(currentVersion = versionProvider.get() ?: "")
private fun getFileFeatureToggles(): Map<String, Boolean> {
val appVersion = versionProvider.get()
return FeatureToggles.values.defineTogglesAvailability(appVersion = appVersion)
}
override fun isFeatureEnabled(name: String): Boolean = featureToggles!![name] ?: false
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun getProdFeatureToggles() = featureToggles!!
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun setProdFeatureToggles(map: Map<String, Boolean>) {
featureToggles = map
}
fun getProdFeatureToggles() = featureToggles
}

View file

@ -0,0 +1,31 @@
package com.tangem.core.configtoggle.storage
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.datasource.local.preferences.utils.storeObjectMap
/**
* Local storage for feature toggles
*
* @property appPreferencesStore app preferences store
*
[REDACTED_AUTHOR]
*/
internal class FeatureTogglesLocalStorage(
private val appPreferencesStore: AppPreferencesStore,
) {
suspend fun getSyncOrEmpty(): Map<String, Boolean> {
return appPreferencesStore.getObjectMapSync<Boolean>(key = FEATURE_TOGGLES_KEY)
}
suspend fun store(value: Map<String, Boolean>) {
appPreferencesStore.storeObjectMap(key = FEATURE_TOGGLES_KEY, value = value)
}
private companion object {
val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") }
}
}

View file

@ -10,4 +10,14 @@ internal fun List<ConfigToggle>.associateToggles(currentVersion: String): Map<St
second = VersionAvailabilityContract(currentVersion, localToggle.version),
)
}
}
internal fun Map<String, String>.defineTogglesAvailability(appVersion: String?): Map<String, Boolean> {
return if (appVersion == null) {
mapValues { false }
} else {
mapValues { (_, version) ->
VersionAvailabilityContract(currentVersion = appVersion, localVersion = version)
}
}
}

View file

@ -0,0 +1,489 @@
package com.tangem.core.configtoggle.manager
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.manager.ProdFeatureTogglesManagerTest.IsFeatureEnabledModel
import com.tangem.core.configtoggle.storage.FeatureTogglesLocalStorage
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DevFeatureTogglesManagerTest {
private val versionProvider = mockk<VersionProvider>()
private val featureTogglesLocalStorage = mockk<FeatureTogglesLocalStorage>(relaxUnitFun = true)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@BeforeAll
fun setupAll() {
val featureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0")
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
}
@AfterAll
fun tearDownAll() {
unmockkObject(FeatureToggles)
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider, featureTogglesLocalStorage)
}
@Test
fun `successfully initialize manager`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@Test
fun `successfully initialize manager if versionProvider returns null`() = runTest {
// Arrange
val appVersion = null
val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@Test
fun `successfully initialize manager if versionProvider returns empty string`() = runTest {
// Arrange
val appVersion = ""
val savedFeatureToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to true)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = savedFeatureToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@Test
fun `failure initialize manager if versionProvider throws exception`() = runTest {
// Arrange
val exception = Exception("Test exception")
every { versionProvider.get() } throws exception
// Act
val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) }
.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder { versionProvider.get() }
coVerify(inverse = true) { featureTogglesLocalStorage.getSyncOrEmpty() }
}
@Test
fun `successfully initialize manager if storage returns empty map`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedFeatureToggles = emptyMap<String, Boolean>()
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@Test
fun `successfully initialize manager if storage returns unknown toggles`() = runTest {
// Arrange
val appVersion = "1.0.0"
val savedFeatureToggles = mapOf("TOGGLE_3" to true)
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
// Act
val actual = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).getFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
@Test
fun `failure initialize manager if storage throws exception`() = runTest {
// Arrange
val appVersion = "1.0.0"
val exception = Exception("Test exception")
every { versionProvider.get() } returns appVersion
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } throws exception
// Act
val actual = runCatching { DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage) }
.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsFeatureEnabled {
private lateinit var manager: DevFeatureTogglesManager
@BeforeAll
fun setupAll() {
every { versionProvider.get() } returns "1.0.0"
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
val savedFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
}
@AfterAll
fun tearDownAll() {
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
@ParameterizedTest
@ProvideTestModels
fun isFeatureEnabled(model: IsFeatureEnabledModel) {
// Act
val actual = manager.isFeatureEnabled(name = model.name)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
private fun provideTestModels() = listOf(
IsFeatureEnabledModel(name = "ACTIVE2_TEST_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "INACTIVE_TEST_FEATURE_ENABLED", expected = true),
IsFeatureEnabledModel(name = "UNKNOWN_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "", expected = false),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsMatchLocalConfig {
@ParameterizedTest
@ProvideTestModels
fun isMatchLocalConfig(model: IsMatchLocalConfigModel) = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns model.fileFeatureToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage).apply {
setFeatureToggles(model.storedFeatureToggles.toMutableMap())
}
// Act
val actual = manager.isMatchLocalConfig()
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
private fun provideTestModels() = listOf(
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false),
expected = true,
),
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to true),
expected = false,
),
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_1" to true),
expected = false,
),
IsMatchLocalConfigModel(
fileFeatureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0"),
storedFeatureToggles = mapOf("TOGGLE_3" to true, "TOGGLE_4" to false),
expected = false,
),
IsMatchLocalConfigModel(
fileFeatureToggles = emptyMap(),
storedFeatureToggles = emptyMap(),
expected = true,
),
IsMatchLocalConfigModel(
fileFeatureToggles = emptyMap(),
storedFeatureToggles = mapOf("TOGGLE_1" to true),
expected = false,
),
)
}
data class IsMatchLocalConfigModel(
val fileFeatureToggles: Map<String, String>,
val storedFeatureToggles: Map<String, Boolean>,
val expected: Boolean,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetFeatureToggles {
@Test
fun getFeatureToggles() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val fileFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns fileFeatureToggles
val savedFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
// Act
val actual = manager.getFeatureToggles()
// Assert
val expected = savedFeatureToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ChangeToggle {
@ParameterizedTest
@ProvideTestModels
fun changeToggle(model: ChangeToggleModel) = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns model.initialToggles
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns emptyMap()
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
// Act
manager.changeToggle(name = model.name, isEnabled = model.isEnabled)
val actual = manager.getFeatureToggles()
// Assert
val expected = model.expectedToggles
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
if (model.expectedStoreSaving) {
featureTogglesLocalStorage.store(model.expectedToggles)
}
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
private fun provideTestModels() = listOf(
ChangeToggleModel(
initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"),
name = "TOGGLE_1",
isEnabled = false,
expectedToggles = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false),
expectedStoreSaving = true,
),
ChangeToggleModel(
initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"),
name = "TOGGLE_2",
isEnabled = true,
expectedToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to true),
expectedStoreSaving = true,
),
ChangeToggleModel(
initialToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "undefined"),
name = "TOGGLE_3",
isEnabled = true,
expectedToggles = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false),
expectedStoreSaving = false,
),
ChangeToggleModel(
initialToggles = emptyMap(),
name = "TOGGLE_1",
isEnabled = true,
expectedToggles = emptyMap(),
expectedStoreSaving = false,
),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class RecoverLocalConfig {
@Test
fun recoverLocalConfig() = runTest {
// Arrange
every { versionProvider.get() } returns "1.0.0"
val fileFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns fileFeatureToggles
val savedFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
coEvery { featureTogglesLocalStorage.getSyncOrEmpty() } returns savedFeatureToggles
val manager = DevFeatureTogglesManager(versionProvider, featureTogglesLocalStorage)
// Act
manager.recoverLocalConfig()
val actual = manager.getFeatureToggles()
// Assert
val expected = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder {
versionProvider.get()
featureTogglesLocalStorage.getSyncOrEmpty()
featureTogglesLocalStorage.store(expected)
}
clearMocks(versionProvider, featureTogglesLocalStorage)
unmockkObject(FeatureToggles)
}
}
data class ChangeToggleModel(
val initialToggles: Map<String, String>,
val name: String,
val isEnabled: Boolean,
val expectedToggles: Map<String, Boolean>,
val expectedStoreSaving: Boolean,
)
}

View file

@ -1,237 +0,0 @@
package com.tangem.core.configtoggle.manager
import android.annotation.SuppressLint
import com.google.common.truth.Truth
import com.squareup.moshi.Moshi
import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.core.configtoggle.storage.ConfigToggle
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.version.VersionProvider
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.collections.set
/**
[REDACTED_AUTHOR]
*/
@SuppressLint("CheckResult")
internal class DevTogglesManagerTest {
private val localTogglesStorage = mockk<TogglesStorage>()
private val appPreferenceStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = mockk(relaxed = true),
)
private val versionProvider = mockk<VersionProvider>()
private val manager = DevFeatureTogglesManager(
localTogglesStorage = localTogglesStorage,
appPreferencesStore = appPreferenceStore,
versionProvider = versionProvider,
)
@Test
fun `successfully initialize storage if shared prefs kept feature toggles`() = runTest {
val currentVersion = "0.1.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery {
appPreferenceStore.getObjectSyncOrNull<Map<String, Boolean>>(PreferencesKeys.FEATURE_TOGGLES_KEY)
} returns savedFeatureTogglesMap
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion)
.mapValues { resultToggle ->
savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value
}
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if shared prefs kept empty list`() = runTest {
val currentVersion = "0.1.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery {
appPreferenceStore.getObjectSyncOrNull<Map<String, Boolean>>(PreferencesKeys.FEATURE_TOGGLES_KEY)
} returns emptyMap()
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion)
.mapValues { resultToggle ->
savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value
}
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if shared prefs didn't keep feature toggles`() = runTest {
val currentVersion = "0.1.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery {
appPreferenceStore.getObjectSyncOrNull<Map<String, Boolean>>(PreferencesKeys.FEATURE_TOGGLES_KEY)
} returns null
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion)
.mapValues(Map.Entry<String, Boolean>::value)
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if versionProvider returns null`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
coEvery { appPreferenceStore.getSyncOrNull(PreferencesKeys.FEATURE_TOGGLES_KEY) } returns savedFeatureToggles
coEvery { localTogglesStorage.toggles } returns localFeatureToggles
coEvery { versionProvider.get() } returns null
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles
.associateToggles(currentVersion = "")
.mapValues { resultToggle ->
savedFeatureTogglesMap[resultToggle.key] ?: resultToggle.value
}
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `get feature availability if feature toggle exists`() {
val featureToggles = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "INACTIVE_TEST_FEATURE_ENABLED")
Truth.assertThat(actual).isTrue()
}
@Test
fun `get feature availability if feature toggle doesn't exists`() {
val featureToggles = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "")
Truth.assertThat(actual).isFalse()
}
@Test
fun getFeatureToggles() {
val expected = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
manager.setFeatureToggles(expected)
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `change toggle that contains in map`() = runTest {
val changeableToggleName = "INACTIVE_TEST_FEATURE_ENABLED"
val resultMap = mutableMapOf(
changeableToggleName to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
manager.setFeatureToggles(resultMap)
manager.changeToggle(changeableToggleName, true)
resultMap[changeableToggleName] = true
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(resultMap)
}
@Test
fun `change toggle that doesn't contains in map`() = runTest {
val resultMap = mutableMapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
manager.setFeatureToggles(resultMap)
manager.changeToggle("FEATURE_TOGGLE", true)
Truth.assertThat(manager.getFeatureToggles()).containsExactlyEntriesIn(resultMap)
}
private companion object {
val savedFeatureToggles = """
[
{
"name": "INACTIVE_TEST_FEATURE_ENABLED",
"version": "undefined"
},
{
"name": "ACTIVE2_TEST_FEATURE_ENABLED",
"version": "1.0.0"
}
]
""".trimIndent()
val savedFeatureTogglesMap = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
val localFeatureToggles = listOf(
ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"),
ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"),
)
}
}

View file

@ -0,0 +1,155 @@
package com.tangem.core.configtoggle.manager
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ProdFeatureTogglesManagerTest {
private val versionProvider = mockk<VersionProvider>()
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Initialization {
@BeforeAll
fun setupAll() {
val featureToggles = mapOf("TOGGLE_1" to "1.0.0", "TOGGLE_2" to "2.0.0")
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
}
@AfterAll
fun tearDownAll() {
unmockkObject(FeatureToggles)
}
@AfterEach
fun tearDownEach() {
clearMocks(versionProvider)
}
@Test
fun `successfully initialize storage`() = runTest {
// Arrange
val appVersion = "1.0.0"
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to true, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `successfully initialize storage if versionProvider returns null`() = runTest {
// Arrange
val appVersion = null
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `successfully initialize storage if versionProvider returns empty string`() = runTest {
// Arrange
val appVersion = ""
every { versionProvider.get() } returns appVersion
// Act
val actual = ProdFeatureTogglesManager(versionProvider).getProdFeatureToggles()
// Assert
val expected = mapOf("TOGGLE_1" to false, "TOGGLE_2" to false)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
coVerifyOrder { versionProvider.get() }
}
@Test
fun `failure initialize storage if versionProvider throws exception`() = runTest {
// Arrange
val exception = Exception("Test exception")
every { versionProvider.get() } throws exception
// Act
val actual = runCatching { ProdFeatureTogglesManager(versionProvider) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isInstanceOf(exception::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder { versionProvider.get() }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsFeatureEnabled {
private lateinit var manager: ProdFeatureTogglesManager
@BeforeAll
fun setupAll() {
every { versionProvider.get() } returns "1.0.0"
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to "undefined",
"ACTIVE2_TEST_FEATURE_ENABLED" to "1.0.0",
)
mockkObject(FeatureToggles)
every { FeatureToggles.values } returns featureToggles
manager = ProdFeatureTogglesManager(versionProvider)
}
@AfterAll
fun tearDownAll() {
clearMocks(versionProvider)
unmockkObject(FeatureToggles)
}
@ParameterizedTest
@ProvideTestModels
fun isFeatureEnabled(model: IsFeatureEnabledModel) {
// Act
val actual = manager.isFeatureEnabled(name = model.name)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
private fun provideTestModels() = listOf(
IsFeatureEnabledModel(name = "ACTIVE2_TEST_FEATURE_ENABLED", expected = true),
IsFeatureEnabledModel(name = "INACTIVE_TEST_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "UNKNOWN_FEATURE_ENABLED", expected = false),
IsFeatureEnabledModel(name = "", expected = false),
)
}
data class IsFeatureEnabledModel(val name: String, val expected: Boolean)
}

View file

@ -1,136 +0,0 @@
package com.tangem.core.configtoggle.manager
import android.content.pm.PackageManager
import com.google.common.truth.Truth
import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants
import com.tangem.core.configtoggle.feature.impl.ProdFeatureTogglesManager
import com.tangem.core.configtoggle.storage.ConfigToggle
import com.tangem.core.configtoggle.storage.TogglesStorage
import com.tangem.core.configtoggle.utils.associateToggles
import com.tangem.core.configtoggle.version.VersionProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class ProdTogglesManagerTest {
private val localTogglesStorage = mockk<TogglesStorage>()
private val versionProvider = mockk<VersionProvider>()
private val manager = ProdFeatureTogglesManager(localTogglesStorage, versionProvider)
@Test
fun `successfully initialize storage`() = runTest {
val currentVersion = "1.0.0"
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } returns localFeatureToggles
every { versionProvider.get() } returns currentVersion
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
val expected = localFeatureToggles.associateToggles(currentVersion)
Truth.assertThat(manager.getProdFeatureToggles()).containsExactlyEntriesIn(expected)
}
@Test
fun `successfully initialize storage if versionProvider returns null`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } returns localFeatureToggles
every { versionProvider.get() } returns null
manager.init()
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
Truth.assertThat(manager.getProdFeatureToggles()).containsExactlyEntriesIn(disabledFeatureToggles)
}
@Test
fun `failure initialize storage if localFeatureTogglesStorage throws exception`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } throws IllegalStateException(
"Property featureToggles should be initialized before get.",
)
runCatching { manager.init() }
.onSuccess { throw IllegalStateException("localFeatureToggles shouldn't be initialized") }
.onFailure {
Truth
.assertThat(it)
.hasMessageThat()
.contains("Property featureToggles should be initialized before get.")
Truth.assertThat(it).isInstanceOf(IllegalStateException::class.java)
}
coVerifyOrder { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) }
verifyAll(inverse = true) { versionProvider.get() }
}
@Test
fun `failure initialize storage if versionProvider throws exception`() = runTest {
coEvery { localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH) } just Runs
every { localTogglesStorage.toggles } returns localFeatureToggles
every { versionProvider.get() } throws PackageManager.NameNotFoundException()
runCatching { manager.init() }
.onSuccess { throw IllegalStateException("versionProvider should throws exception") }
.onFailure {
Truth.assertThat(it).isInstanceOf(PackageManager.NameNotFoundException::class.java)
}
coVerifyOrder {
localTogglesStorage.populate(FeatureTogglesConstants.LOCAL_CONFIG_PATH)
versionProvider.get()
}
}
@Test
fun `get feature availability if feature toggle exists`() {
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to true,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setProdFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "INACTIVE_TEST_FEATURE_ENABLED")
Truth.assertThat(actual).isTrue()
}
@Test
fun `get feature availability if feature toggle doesn't exists`() {
val featureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to true,
)
manager.setProdFeatureToggles(featureToggles)
val actual = manager.isFeatureEnabled(name = "")
Truth.assertThat(actual).isFalse()
}
private companion object {
val localFeatureToggles = listOf(
ConfigToggle(name = "INACTIVE_TEST_FEATURE_ENABLED", version = "undefined"),
ConfigToggle(name = "ACTIVE2_TEST_FEATURE_ENABLED", version = "1.0.0"),
)
val disabledFeatureToggles = mapOf(
"INACTIVE_TEST_FEATURE_ENABLED" to false,
"ACTIVE2_TEST_FEATURE_ENABLED" to false,
)
}
}

View file

@ -154,7 +154,8 @@ interface TangemTechApi {
suspend fun saveWalletAccounts(
@Path("walletId") walletId: String,
@Header("If-Match") ifMatch: String,
): ApiResponse<SaveWalletAccountsResponse>
@Body body: SaveWalletAccountsResponse,
): ApiResponse<Unit>
@GET("/v1/wallets/{walletId}/accounts/archived")
suspend fun getWalletArchivedAccounts(

View file

@ -14,11 +14,18 @@ interface RuntimeStateStore<T> {
/** Get flow of elements [T] */
fun get(): StateFlow<T>
/** Get element [T] synchronously or null */
suspend fun getSyncOrNull(): T?
/** Store [value] */
suspend fun store(value: T)
/** Update current value by [function] */
suspend fun update(function: (T) -> T)
/** Clear stored value */
fun clear()
companion object {
/**
@ -32,6 +39,8 @@ interface RuntimeStateStore<T> {
override fun get(): StateFlow<T> = flow
override suspend fun getSyncOrNull(): T? = flow.value
override suspend fun store(value: T) {
flow.value = value
}
@ -39,6 +48,10 @@ interface RuntimeStateStore<T> {
override suspend fun update(function: (T) -> T) {
flow.update(function)
}
override fun clear() {
flow.value = defaultValue
}
}
}
}

View file

@ -56,8 +56,6 @@ object PreferencesKeys {
val LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY by lazy { stringPreferencesKey(name = "lastSwappedCryptoCurrency") }
val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") }
val EXCLUDED_BLOCKCHAINS_KEY by lazy { stringPreferencesKey(name = "excludedBlockchainsV2") }
val WAS_TWINS_ONBOARDING_SHOWN by lazy { booleanPreferencesKey(name = "twinsOnboardingShown") }

View file

@ -32,6 +32,12 @@ sealed class TopAppBarButtonUM(
enabled = enabled,
)
fun Close(enabled: Boolean = true, onCloseClick: () -> Unit) = Icon(
iconRes = R.drawable.ic_close_24,
onClicked = onCloseClick,
enabled = enabled,
)
fun Text(text: TextReference, onTextClicked: () -> Unit, enabled: Boolean = true) = Text(
text = text,
onClicked = onTextClicked,

View file

@ -1,6 +1,7 @@
package com.tangem.core.ui.components.bottomsheets.modal
import android.content.res.Configuration
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
@ -49,7 +50,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> TangemModalBottomSheetWi
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit = {},
crossinline content: @Composable (T) -> Unit,
crossinline footer: @Composable (BoxScope.(T) -> Unit),
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
) {
val isAlwaysVisible = LocalBottomSheetAlwaysVisible.current
@ -84,7 +85,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> DefaultModalBottomSheetW
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
crossinline footer: @Composable (BoxScope.(T) -> Unit),
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
) {
var isVisible by remember { mutableStateOf(value = config.isShown) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded)
@ -118,7 +119,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> PreviewModalBottomSheetW
skipPartiallyExpanded: Boolean = true,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
crossinline footer: @Composable BoxScope.(T) -> Unit,
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
) {
BasicModalBottomSheetWithFooter<T>(
config = config,
@ -145,7 +146,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
crossinline footer: @Composable (BoxScope.(T) -> Unit),
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
modifier: Modifier = Modifier,
) {
val model = config.content as? T ?: return
@ -156,8 +157,13 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
val scrollState = rememberScrollState(initial = initial)
val isKeyboardOpen by rememberIsKeyboardVisible()
val buttonHeight = TangemTheme.dimens.spacing80
val contentBottomPadding = TangemTheme.dimens.spacing80
val buttonHeight by animateDpAsState(
if (footer != null) {
80.dp
} else {
0.dp
},
)
// Offset calculation for keyboard scroll adjustment:
// 1) Button height (footer)
// 2) Column content bottom padding
@ -202,7 +208,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
Column(
modifier = Modifier
.verticalScroll(state = scrollState)
.padding(bottom = contentBottomPadding),
.padding(bottom = buttonHeight),
) {
content(model)
}
@ -218,7 +224,9 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
.height(buttonHeight)
.align(Alignment.BottomCenter),
) {
footer(model)
if (footer != null) {
footer(model)
}
}
}
}

View file

@ -25,7 +25,6 @@ import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.utils.StringsSigns.PASSWORD_VISUAL_CHAR
@ -159,14 +158,17 @@ private fun CellDecoration(
)
}
} else {
Text(
Box(
modifier = Modifier.sizeIn(minWidth = minWidth, minHeight = minHeight),
text = text,
style = TangemTheme.typography.h3,
color = color,
textAlign = TextAlign.Center,
lineHeight = 48.sp,
)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = text,
style = TangemTheme.typography.h3,
color = color,
textAlign = TextAlign.Center,
)
}
}
}
}

View file

@ -0,0 +1,104 @@
package com.tangem.core.ui.components.grid
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import com.tangem.core.ui.components.grid.entity.EnumeratedTwoColumnGridItem
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* EnumeratedTwoColumnGrid component
*
* @param items component items
* @param modifier composable modifier
*
*/
@Composable
fun EnumeratedTwoColumnGrid(items: ImmutableList<EnumeratedTwoColumnGridItem>, modifier: Modifier = Modifier) {
VerticalGrid(
modifier = modifier,
items = items,
) { item ->
Row(
modifier = Modifier.padding(all = TangemTheme.dimens.size8),
verticalAlignment = Alignment.CenterVertically,
) {
if (LocalLayoutDirection.current == LayoutDirection.Ltr) {
Text(
modifier = Modifier.width(TangemTheme.dimens.size40),
text = "${item.index}.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
Text(
text = item.mnemonic,
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
} else {
Text(
text = item.mnemonic,
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier.width(TangemTheme.dimens.size40),
text = "${item.index}.",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
}
@Composable
private inline fun <T> VerticalGrid(
items: ImmutableList<T>,
modifier: Modifier = Modifier,
crossinline content: @Composable (T) -> Unit,
) {
val columnLength = items.size / 2
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
repeat(2) { index ->
Column {
for (i in 0 until columnLength) {
val item = items[index * columnLength + i]
content(item)
}
}
}
}
}
@Preview(widthDp = 360, heightDp = 640, showBackground = true)
@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
EnumeratedTwoColumnGrid(
items = List(24) {
EnumeratedTwoColumnGridItem(
index = it + 1,
mnemonic = "word${it + 1}",
)
}.toImmutableList(),
)
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.core.ui.components.grid.entity
data class EnumeratedTwoColumnGridItem(
val index: Int,
val mnemonic: String,
)

View file

@ -27,7 +27,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
@ -63,8 +62,8 @@ fun Notification(
NotificationConfig.IconTint.Unspecified -> null
NotificationConfig.IconTint.Accent -> TangemTheme.colors.icon.accent
NotificationConfig.IconTint.Attention -> TangemTheme.colors.icon.attention
NotificationConfig.IconTint.Warning -> TangemTheme.colors.icon.warning
},
iconSize: Dp = 20.dp,
isEnabled: Boolean = true,
) {
NotificationBaseContainer(
@ -78,7 +77,7 @@ fun Notification(
MainContent(
iconResId = config.iconResId,
iconTint = iconTint,
iconSize = iconSize,
iconSize = config.iconSize,
title = config.title,
titleColor = titleColor,
subtitle = config.subtitle,

View file

@ -1,6 +1,8 @@
package com.tangem.core.ui.components.notifications
import androidx.annotation.DrawableRes
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.TextReference
/**
@ -26,6 +28,7 @@ data class NotificationConfig(
val onCloseClick: (() -> Unit)? = null,
val showArrowIcon: Boolean = onClick != null,
val iconTint: IconTint = IconTint.Unspecified,
val iconSize: Dp = 20.dp,
) {
sealed class ButtonsState {
@ -62,5 +65,6 @@ data class NotificationConfig(
Unspecified,
Accent,
Attention,
Warning,
}
}

View file

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="36"
android:viewportHeight="36">
<group>
<clip-path
android:pathData="M0,0h36v36h-36z"/>
<path
android:pathData="M18,18m-18,0a18,18 0,1 1,36 0a18,18 0,1 1,-36 0"
android:fillColor="#EBEBEB"/>
<path
android:pathData="M21.682,9.25H14.318C13.156,9.25 12.576,9.25 12.132,9.476C11.742,9.675 11.424,9.992 11.226,10.382C11,10.826 11,11.406 11,12.568V13.8H25V12.568C25,11.406 25,10.826 24.774,10.382C24.575,9.992 24.258,9.674 23.868,9.476C23.424,9.25 22.844,9.25 21.682,9.25Z"
android:fillColor="#1E1E1E"/>
<path
android:pathData="M20.333,17.65H25V23.432C25,24.594 25,25.174 24.773,25.618C24.574,26.008 24.258,26.326 23.868,26.524C23.423,26.75 22.843,26.75 21.681,26.75H20.334L20.333,17.65Z"
android:fillColor="#1E1E1E"/>
<path
android:pathData="M11,17.65H15.666V26.75H14.318C13.157,26.75 12.576,26.75 12.132,26.524C11.742,26.326 11.425,26.008 11.226,25.618C11,25.174 11,24.594 11,23.432V17.65Z"
android:fillColor="#1E1E1E"/>
</group>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M11.999,1.846C13.546,1.846 14.796,2.398 15.854,2.976C16.977,3.588 17.75,4.132 18.658,4.438C19.267,4.642 20.029,4.812 20.464,5.322C20.812,5.73 20.891,6.27 20.979,6.78C22,12.753 19.662,19.35 13.72,21.75C13.155,21.978 12.718,22.154 12.002,22.154C11.286,22.154 10.849,21.978 10.284,21.75C4.342,19.35 2.002,12.748 3.022,6.78C3.11,6.27 3.189,5.73 3.537,5.322C3.972,4.812 4.734,4.642 5.343,4.438C6.25,4.132 7.023,3.588 8.145,2.976C9.202,2.398 10.452,1.846 11.999,1.846ZM16.903,8.645C16.577,8.346 16.072,8.368 15.773,8.694L10.963,13.949L9.146,11.966C8.848,11.64 8.343,11.618 8.017,11.916C7.691,12.214 7.669,12.72 7.967,13.046L10.373,15.675C10.524,15.84 10.739,15.934 10.963,15.935C11.187,15.935 11.402,15.84 11.554,15.675L16.953,9.774C17.251,9.449 17.229,8.943 16.903,8.645Z"
android:fillColor="#656565"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13.513,2.602C13.689,2.603 13.859,2.604 14.024,2.607C14.049,2.608 14.074,2.609 14.098,2.609C14.688,2.624 15.209,2.654 15.665,2.716C16.585,2.84 17.344,3.102 17.943,3.701C18.543,4.301 18.805,5.059 18.929,5.979C19.049,6.87 19.049,8.006 19.049,9.43V14.074C19.005,14.067 18.964,14.06 18.926,14.055C18.764,14.033 18.598,14.022 18.455,14.018L18.121,14.015H12.122C11.101,12.924 9.649,12.225 8.019,12.225C7.018,12.225 6.087,12.484 5.281,12.936V9.43C5.281,8.006 5.282,6.87 5.401,5.979C5.525,5.059 5.787,4.301 6.387,3.701C6.986,3.102 7.745,2.84 8.665,2.716C9.121,2.655 9.641,2.624 10.231,2.609C10.255,2.609 10.28,2.608 10.306,2.607C10.47,2.604 10.641,2.603 10.816,2.602C11.22,2.597 11.652,2.596 12.115,2.596H12.215C12.677,2.596 13.109,2.597 13.513,2.602ZM11.276,5.039C10.786,5.039 10.389,5.436 10.389,5.927C10.389,6.417 10.786,6.815 11.276,6.815H13.053C13.543,6.815 13.941,6.417 13.941,5.927C13.941,5.436 13.543,5.039 13.053,5.039H11.276Z"
android:fillColor="#656565"/>
<path
android:pathData="M8.019,14.225C9.252,14.225 10.337,14.846 10.997,15.79C11.077,15.904 11.117,15.961 11.168,15.987C11.219,16.014 11.283,16.015 11.409,16.015H18.121C18.302,16.015 18.493,16.014 18.653,16.036C18.84,16.062 19.069,16.127 19.26,16.321C19.372,16.436 19.437,16.573 19.475,16.67C19.515,16.776 19.545,16.889 19.566,16.998C19.61,17.216 19.631,17.465 19.633,17.705C19.635,17.944 19.618,18.197 19.575,18.422C19.554,18.534 19.524,18.652 19.48,18.763C19.44,18.867 19.373,19.002 19.26,19.118C19.069,19.313 18.84,19.379 18.653,19.404C18.57,19.416 18.313,19.42 18.087,19.423C17.901,19.425 17.807,19.426 17.743,19.479C17.679,19.533 17.661,19.624 17.625,19.805C17.542,20.223 17.415,20.85 17.387,20.938C17.337,21.092 17.251,21.275 17.074,21.424C16.895,21.574 16.698,21.625 16.535,21.646L14.904,21.641C14.718,21.615 14.488,21.551 14.297,21.355C14.108,21.162 14.047,20.933 14.023,20.748C14.001,20.587 14.001,20.395 14.001,20.209V19.896C14.001,19.674 14.001,19.563 13.933,19.494C13.864,19.426 13.752,19.426 13.531,19.426H11.68C11.534,19.426 11.46,19.425 11.404,19.459C11.349,19.493 11.311,19.564 11.236,19.705C10.622,20.866 9.416,21.663 8.019,21.663C5.989,21.663 4.365,19.986 4.365,17.943C4.365,15.901 5.989,14.225 8.019,14.225ZM8.079,16.77C7.433,16.77 6.91,17.296 6.91,17.944C6.91,18.593 7.434,19.118 8.079,19.118H8.09C8.735,19.118 9.259,18.593 9.259,17.944C9.259,17.296 8.736,16.77 8.09,16.77H8.079Z"
android:fillColor="#656565"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,3C14.623,3 16.75,5.127 16.75,7.75V9.75H18C18.552,9.75 19,10.198 19,10.75V19.75C19,20.302 18.552,20.75 18,20.75H6C5.448,20.75 5,20.302 5,19.75V10.75C5,10.198 5.448,9.75 6,9.75H7.25V7.75C7.25,5.127 9.377,3 12,3ZM12,4.5C10.205,4.5 8.75,5.955 8.75,7.75V9.75H15.25V7.75C15.25,5.955 13.795,4.5 12,4.5Z"
android:fillColor="#656565"/>
</vector>

View file

@ -6,11 +6,11 @@
android:viewportHeight="24">
<path
android:pathData="M15.682,3.25H8.318C7.157,3.25 6.576,3.25 6.132,3.476C5.742,3.675 5.424,3.992 5.226,4.382C5,4.826 5,5.407 5,6.568V7.8H19V6.568C19,5.407 19,4.826 18.774,4.382C18.575,3.992 18.258,3.674 17.868,3.476C17.424,3.25 16.844,3.25 15.682,3.25Z"
android:fillColor="#1E1E1E"/>
android:fillColor="@color/icon_primary1"/>
<path
android:pathData="M14.333,11.65H19V17.432C19,18.594 19,19.174 18.773,19.618C18.574,20.008 18.258,20.326 17.868,20.524C17.423,20.75 16.843,20.75 15.681,20.75H14.334L14.333,11.65Z"
android:fillColor="#1E1E1E"/>
android:fillColor="@color/icon_primary1"/>
<path
android:pathData="M5,11.65H9.666V20.75H8.318C7.157,20.75 6.576,20.75 6.132,20.524C5.742,20.326 5.425,20.008 5.226,19.618C5,19.174 5,18.594 5,17.432V11.65Z"
android:fillColor="#1E1E1E"/>
android:fillColor="@color/icon_primary1"/>
</vector>

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M17.778,0L46.222,0A17.778,17.778 0,0 1,64 17.778L64,46.222A17.778,17.778 0,0 1,46.222 64L17.778,64A17.778,17.778 0,0 1,0 46.222L0,17.778A17.778,17.778 0,0 1,17.778 0z"
android:fillColor="#EBEBEB"/>
<path
android:pathData="M28.525,31.48V45.021H26.519C24.791,45.021 23.927,45.021 23.267,44.684C22.686,44.39 22.215,43.917 21.919,43.337C21.582,42.676 21.582,41.812 21.582,40.083V31.48H28.525Z"
android:fillColor="#1E1E1E"/>
<path
android:pathData="M42.414,40.083C42.414,41.812 42.414,42.676 42.077,43.337C41.781,43.917 41.31,44.39 40.73,44.684C40.069,45.021 39.205,45.021 37.476,45.021H35.471V32.441L35.47,31.48H42.414V40.083Z"
android:fillColor="#1E1E1E"/>
<path
android:pathData="M37.477,18.981C39.205,18.981 40.069,18.981 40.73,19.318C41.31,19.612 41.781,20.085 42.077,20.666C42.414,21.326 42.414,22.19 42.414,23.918V25.751H21.582V23.918C21.582,22.19 21.582,21.326 21.919,20.666C22.213,20.085 22.686,19.614 23.267,19.318C23.927,18.981 24.791,18.981 26.519,18.981H37.477Z"
android:fillColor="#1E1E1E"/>
</vector>

View file

@ -1,15 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp"
android:height="96dp"
android:width="82dp"
android:height="82dp"
android:viewportWidth="450"
android:viewportHeight="450">
<path
android:fillColor="#ffffff"
android:fillColor="@color/icon_primary1"
android:pathData="M284.96,82H165.04C146.12,82 136.67,82 129.44,85.68C123.08,88.93 117.91,94.08 114.68,100.44C111,107.67 111,117.12 111,136.04V156.1H339V136.04C339,117.12 339,107.67 335.32,100.44C332.07,94.08 326.92,88.91 320.56,85.68C313.33,82 303.88,82 284.96,82Z" />
<path
android:fillColor="#ffffff"
android:fillColor="@color/icon_primary1"
android:pathData="M262.99,218.8H338.99V312.96C338.99,331.88 338.99,341.33 335.31,348.56C332.07,354.92 326.91,360.09 320.56,363.32C313.32,367 303.87,367 284.95,367H263.01L262.99,218.8Z" />
<path
android:fillColor="#ffffff"
android:fillColor="@color/icon_primary1"
android:pathData="M111,218.8H186.99V367H165.04C146.12,367 136.67,367 129.44,363.32C123.08,360.09 117.93,354.92 114.68,348.56C111,341.33 111,331.88 111,312.96V218.8Z" />
</vector>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="icon_primary1">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="icon_primary1">#FF1E1E1E</color>
</resources>

View file

@ -0,0 +1,6 @@
package com.tangem.utils.buildConfig
interface AppConfigurationProvider {
fun isDebug(): Boolean
fun isHuawei(): Boolean
}

View file

@ -9,9 +9,14 @@ android {
namespace = "com.tangem.data.account"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
api(projects.core.utils)
// endregion
@ -20,18 +25,33 @@ dependencies {
api(projects.domain.models)
// endregion
// Project - Data
implementation(projects.core.datasource)
// region Project - Data
implementation(projects.data.common)
// endregion
// region DI
implementation(deps.hilt.core)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region AndroidX libraries
implementation(deps.androidx.datastore)
// endregion
// region Other Dependencies
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.timber)
// endregion
// region Test
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
// endregion
}

View file

@ -0,0 +1,36 @@
package com.tangem.data.account.converter
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.wallet.UserWalletId
import javax.inject.Inject
/**
* Container for converter factories related to accounts.
*
* @property accountsListCF factory for creating an account list converter
* @property getWalletAccountsResponseCF factory for creating a wallet accounts response converter
* @property cryptoPortfolioCF factory for creating a crypto portfolio converter
*
* @constructor Creates an instance of the container with injected factories.
*
[REDACTED_AUTHOR]
*/
internal class AccountConverterFactoryContainer @Inject constructor(
val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory,
private val accountsListCF: AccountListConverter.Factory,
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory,
private val userWalletsStore: UserWalletsStore,
) {
fun createAccountListConverter(userWalletId: UserWalletId): AccountListConverter {
val userWallet = userWalletsStore.getSyncStrict(key = userWalletId)
return accountsListCF.create(userWallet)
}
fun createCryptoPortfolioConverter(userWalletId: UserWalletId): CryptoPortfolioConverter {
val userWallet = userWalletsStore.getSyncStrict(key = userWalletId)
return cryptoPortfolioCF.create(userWallet)
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.data.account.converter
import arrow.core.getOrElse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
internal fun String.toAccountId(userWalletId: UserWalletId): AccountId {
return AccountId.forCryptoPortfolio(value = this, userWalletId = userWalletId).getOrElse {
error("Unable to create AccountId from value: $this. Cause: $it")
}
}
internal fun String.toAccountName(): AccountName {
return AccountName(value = this).getOrElse {
error("Unable to create AccountName from value: $this. Cause: $it")
}
}
internal fun WalletAccountDTO.toIcon(): CryptoPortfolioIcon {
return CryptoPortfolioIconConverter.convert(
value = CryptoPortfolioIconConverter.DataModel(icon = icon, color = iconColor),
)
}
internal fun Int.toDerivationIndex(): DerivationIndex {
return DerivationIndex(value = this).getOrElse {
error("Unable to create DerivationIndex from value: $this. Cause: $it")
}
}

View file

@ -0,0 +1,46 @@
package com.tangem.data.account.converter
import arrow.core.getOrElse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.converter.Converter
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Converts a [GetWalletAccountsResponse] to an [AccountList] and vice versa
*
* @property userWallet the user wallet associated with the account list
* @param cryptoPortfolioConverterFactory factory to create [CryptoPortfolioConverter] instances
*
[REDACTED_AUTHOR]
*/
internal class AccountListConverter @AssistedInject constructor(
@Assisted private val userWallet: UserWallet,
cryptoPortfolioConverterFactory: CryptoPortfolioConverter.Factory,
) : Converter<GetWalletAccountsResponse, AccountList> {
private val cryptoPortfolioConverter: CryptoPortfolioConverter by lazy {
cryptoPortfolioConverterFactory.create(userWallet)
}
override fun convert(value: GetWalletAccountsResponse): AccountList {
return AccountList(
userWallet = userWallet,
accounts = value.accounts.map(cryptoPortfolioConverter::convert).toSet(),
totalAccounts = value.wallet.totalAccounts,
sortType = TokensSortTypeConverter.convert(value.wallet.sort),
groupType = TokensGroupTypeConverter.convert(value.wallet.group),
)
.getOrElse {
error("Failed to convert GetWalletAccountsResponse to AccountList: $it")
}
}
@AssistedFactory
interface Factory {
fun create(userWallet: UserWallet): AccountListConverter
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.converter.Converter
/**
* Converts a [WalletAccountDTO] to an [ArchivedAccount]
*
* @param userWalletId the ID of the user wallet associated with the account
*
[REDACTED_AUTHOR]
*/
internal class ArchivedAccountConverter(
private val userWalletId: UserWalletId,
) : Converter<WalletAccountDTO, ArchivedAccount> {
override fun convert(value: WalletAccountDTO): ArchivedAccount {
return ArchivedAccount(
accountId = value.id.toAccountId(userWalletId = userWalletId),
name = value.name.toAccountName(),
icon = value.toIcon(),
derivationIndex = value.derivationIndex.toDerivationIndex(),
tokensCount = value.totalTokens ?: error("Total tokens should not be null"),
networksCount = value.totalNetworks ?: error("Total networks should not be null"),
)
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.data.account.converter
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.converter.TwoWayConverter
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Converts a [WalletAccountDTO] to an [Account.CryptoPortfolio] and vise versa
*
* @property userWallet the user wallet associated with the account list
* @property responseCryptoCurrenciesFactory factory to create crypto currencies from response tokens
*
[REDACTED_AUTHOR]
*/
internal class CryptoPortfolioConverter @AssistedInject constructor(
@Assisted private val userWallet: UserWallet,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
private val userTokensResponseFactory: UserTokensResponseFactory,
) : TwoWayConverter<WalletAccountDTO, Account.CryptoPortfolio> {
override fun convert(value: WalletAccountDTO): Account.CryptoPortfolio {
val tokens = value.tokens ?: error("Tokens should not be null")
return Account.CryptoPortfolio(
accountId = value.id.toAccountId(userWallet.walletId),
accountName = value.name.toAccountName(),
icon = value.toIcon(),
derivationIndex = value.derivationIndex.toDerivationIndex(),
cryptoCurrencies = if (tokens.isNotEmpty()) {
responseCryptoCurrenciesFactory.createCurrencies(
tokens = tokens,
userWallet = userWallet,
).toSet()
} else {
emptySet()
},
)
}
override fun convertBack(value: Account.CryptoPortfolio): WalletAccountDTO {
return WalletAccountDTO(
id = value.accountId.value,
name = value.accountName.value,
derivationIndex = value.derivationIndex.value,
icon = value.icon.value.name,
iconColor = value.icon.color.name,
tokens = value.cryptoCurrencies.map(userTokensResponseFactory::createResponseToken),
)
}
@AssistedFactory
interface Factory {
fun create(userWallet: UserWallet): CryptoPortfolioConverter
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.data.account.converter
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.utils.converter.Converter
/**
* Converts a [CryptoPortfolioIconConverter.DataModel] to a [CryptoPortfolioIcon]
*
[REDACTED_AUTHOR]
*/
internal object CryptoPortfolioIconConverter : Converter<CryptoPortfolioIconConverter.DataModel, CryptoPortfolioIcon> {
override fun convert(value: DataModel): CryptoPortfolioIcon {
return CryptoPortfolioIcon.ofCustomAccount(
value = CryptoPortfolioIcon.Icon.valueOf(value.icon),
color = CryptoPortfolioIcon.Color.valueOf(value.color),
)
}
data class DataModel(val icon: String, val color: String)
}

View file

@ -0,0 +1,44 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.converter.Converter
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
[REDACTED_AUTHOR]
*/
internal class GetWalletAccountsResponseConverter @AssistedInject constructor(
@Assisted private val userWallet: UserWallet,
@Assisted val version: Int,
cryptoPortfolioConverterFactory: CryptoPortfolioConverter.Factory,
) : Converter<AccountList, GetWalletAccountsResponse> {
private val cryptoPortfolioConverter: CryptoPortfolioConverter by lazy {
cryptoPortfolioConverterFactory.create(userWallet)
}
override fun convert(value: AccountList): GetWalletAccountsResponse {
return GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = version,
group = TokensGroupTypeConverter.convertBack(value.groupType),
sort = TokensSortTypeConverter.convertBack(value.sortType),
totalAccounts = value.totalAccounts,
),
accounts = value.accounts
.filterIsInstance<Account.CryptoPortfolio>()
.map(cryptoPortfolioConverter::convertBack),
unassignedTokens = emptyList(),
)
}
@AssistedFactory
interface Factory {
fun create(userWallet: UserWallet, version: Int): GetWalletAccountsResponseConverter
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.utils.converter.Converter
/**
* Converts an [AccountList] to a [SaveWalletAccountsResponse]
*
[REDACTED_AUTHOR]
*/
internal object SaveWalletAccountsResponseConverter : Converter<AccountList, SaveWalletAccountsResponse> {
override fun convert(value: AccountList): SaveWalletAccountsResponse {
return SaveWalletAccountsResponse(
accounts = value.accounts
.filterIsInstance<Account.CryptoPortfolio>()
.map(::toDTO),
)
}
private fun toDTO(account: Account.CryptoPortfolio): WalletAccountDTO {
return WalletAccountDTO(
id = account.accountId.value,
name = account.accountName.value,
derivationIndex = account.derivationIndex.value,
icon = account.icon.value.name,
iconColor = account.icon.color.name,
)
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.TokensGroupType
import com.tangem.utils.converter.TwoWayConverter
/**
* Converts a [UserTokensResponse.GroupType] to a [TokensGroupType] and vice versa
*
[REDACTED_AUTHOR]
*/
internal object TokensGroupTypeConverter : TwoWayConverter<UserTokensResponse.GroupType, TokensGroupType> {
override fun convert(value: UserTokensResponse.GroupType): TokensGroupType {
return when (value) {
UserTokensResponse.GroupType.NETWORK -> TokensGroupType.NETWORK
UserTokensResponse.GroupType.NONE,
UserTokensResponse.GroupType.TOKEN,
-> TokensGroupType.NONE
}
}
override fun convertBack(value: TokensGroupType): UserTokensResponse.GroupType {
return when (value) {
TokensGroupType.NONE -> UserTokensResponse.GroupType.NONE
TokensGroupType.NETWORK -> UserTokensResponse.GroupType.NETWORK
}
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.TokensSortType
import com.tangem.utils.converter.TwoWayConverter
/**
* Converts a [UserTokensResponse.SortType] to a [TokensSortType] and vice versa
*
[REDACTED_AUTHOR]
*/
internal object TokensSortTypeConverter : TwoWayConverter<UserTokensResponse.SortType, TokensSortType> {
override fun convert(value: UserTokensResponse.SortType): TokensSortType {
return when (value) {
UserTokensResponse.SortType.BALANCE -> TokensSortType.BALANCE
UserTokensResponse.SortType.MANUAL,
UserTokensResponse.SortType.MARKETCAP,
-> TokensSortType.NONE
}
}
override fun convertBack(value: TokensSortType): UserTokensResponse.SortType {
return when (value) {
TokensSortType.NONE -> UserTokensResponse.SortType.MANUAL
TokensSortType.BALANCE -> UserTokensResponse.SortType.BALANCE
}
}
}

View file

@ -1,9 +1,13 @@
package com.tangem.data.account.di
import com.tangem.data.account.converter.AccountConverterFactoryContainer
import com.tangem.data.account.repository.DefaultAccountsCRUDRepository
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.ArchivedAccountsStoreFactory
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -16,10 +20,20 @@ internal object AccountDataModule {
@Provides
@Singleton
fun provideAccountsCRUDRepository(userWalletsStore: UserWalletsStore): AccountsCRUDRepository {
fun provideAccountsCRUDRepository(
tangemTechApi: TangemTechApi,
accountsResponseStoreFactory: AccountsResponseStoreFactory,
userWalletsStore: UserWalletsStore,
accountConverterFactoryContainer: AccountConverterFactoryContainer,
dispatchers: CoroutineDispatcherProvider,
): AccountsCRUDRepository {
return DefaultAccountsCRUDRepository(
runtimeStore = RuntimeSharedStore(),
tangemTechApi = tangemTechApi,
accountsResponseStoreFactory = accountsResponseStoreFactory,
archivedAccountsStoreFactory = ArchivedAccountsStoreFactory,
userWalletsStore = userWalletsStore,
convertersContainer = accountConverterFactoryContainer,
dispatchers = dispatchers,
)
}
}

View file

@ -1,90 +1,150 @@
package com.tangem.data.account.repository
import arrow.core.Option
import arrow.core.Option.Companion.catch
import arrow.core.none
import arrow.core.raise.option
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import arrow.core.toOption
import com.tangem.data.account.converter.AccountConverterFactoryContainer
import com.tangem.data.account.converter.ArchivedAccountConverter
import com.tangem.data.account.converter.SaveWalletAccountsResponseConverter
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.ArchivedAccountsStore
import com.tangem.data.account.store.ArchivedAccountsStoreFactory
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.models.account.*
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.extensions.addOrReplace
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
[REDACTED_AUTHOR]
*/
// TODO: [REDACTED_JIRA]
internal class DefaultAccountsCRUDRepository(
private val runtimeStore: RuntimeSharedStore<List<AccountList>>,
private val tangemTechApi: TangemTechApi,
private val accountsResponseStoreFactory: AccountsResponseStoreFactory,
private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory,
private val userWalletsStore: UserWalletsStore,
private val convertersContainer: AccountConverterFactoryContainer,
private val dispatchers: CoroutineDispatcherProvider,
) : AccountsCRUDRepository {
override suspend fun getAccounts(userWalletId: UserWalletId): Option<AccountList> = catch {
runtimeStore.getSyncOrNull()
?.firstOrNull { it.userWallet.walletId == userWalletId }
?: return none()
private val saveAccountsMutex = Mutex()
override suspend fun getAccountListSync(userWalletId: UserWalletId): Option<AccountList> = option {
val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId)
ensureNotNull(accountListResponse)
val converter = convertersContainer.createAccountListConverter(userWalletId = userWalletId)
converter.convert(value = accountListResponse)
}
override suspend fun getAccount(accountId: AccountId): Option<Account.CryptoPortfolio> = catch {
runtimeStore.getSyncOrNull().orEmpty()
.flatMap { it.accounts }
.firstOrNull { it.accountId == accountId } as? Account.CryptoPortfolio
?: return none()
override suspend fun getAccountSync(accountId: AccountId): Option<Account.CryptoPortfolio> = option {
val userWalletId = accountId.userWalletId
val accountResponse = getAccountsResponseSync(userWalletId = userWalletId)
?.accounts?.firstOrNull { it.id == accountId.value }
ensureNotNull(accountResponse)
val converter = convertersContainer.createCryptoPortfolioConverter(userWalletId = userWalletId)
converter.convert(value = accountResponse)
}
override suspend fun getArchivedAccount(accountId: AccountId): Option<ArchivedAccount> = option {
createMockArchivedAccount(userWalletId = accountId.userWalletId)
override suspend fun getArchivedAccountSync(accountId: AccountId): Option<ArchivedAccount> {
val store = getArchivedAccountsStore(userWalletId = accountId.userWalletId)
return store.getSyncOrNull()
?.firstOrNull { it.accountId == accountId }
.toOption()
}
override suspend fun getArchivedAccountsSync(userWalletId: UserWalletId): Option<List<ArchivedAccount>> = option {
listOf(
createMockArchivedAccount(userWalletId),
)
override suspend fun getArchivedAccountListSync(userWalletId: UserWalletId): Option<List<ArchivedAccount>> {
val store = getArchivedAccountsStore(userWalletId = userWalletId)
return store.getSyncOrNull().toOption()
}
override fun getArchivedAccounts(userWalletId: UserWalletId): Flow<List<ArchivedAccount>> {
return flow {
getArchivedAccountsSync(userWalletId).getOrNull().orEmpty()
}
val store = getArchivedAccountsStore(userWalletId = userWalletId)
return store.get()
}
override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) = Unit
override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) {
val response = withContext(dispatchers.io) {
tangemTechApi.getWalletArchivedAccounts(walletId = userWalletId.stringValue).getOrThrow()
}
val store = getArchivedAccountsStore(userWalletId = userWalletId)
val converter = ArchivedAccountConverter(userWalletId = userWalletId)
val archivedAccounts = converter.convertList(input = response.accounts)
store.store(value = archivedAccounts)
}
override suspend fun saveAccounts(accountList: AccountList) {
runtimeStore.update(emptyList()) {
it.addOrReplace(accountList) { it.userWallet.walletId == accountList.userWallet.walletId }
saveAccountsMutex.withLock {
val store = getAccountsResponseStore(userWalletId = accountList.userWallet.walletId)
val version = store.data.firstOrNull()?.wallet?.version ?: 0
val body = SaveWalletAccountsResponseConverter.convert(value = accountList)
withContext(dispatchers.io) {
tangemTechApi.saveWalletAccounts(
walletId = accountList.userWallet.walletId.stringValue,
ifMatch = version.toString(),
body = body,
)
.getOrThrow()
}
val converter = convertersContainer.getWalletAccountsResponseCF.create(
userWallet = accountList.userWallet,
version = version,
)
val accountsResponse = converter.convert(value = accountList)
store.updateData { accountsResponse }
}
}
override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Int {
val activeAccountsCount = runtimeStore.getSyncOrNull()?.size ?: 1
override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Option<Int> = option {
val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId)
return activeAccountsCount + 1
ensureNotNull(accountListResponse)
return accountListResponse.wallet.totalAccounts.toOption()
}
override fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return userWalletsStore.getSyncStrict(userWalletId)
}
private fun createMockArchivedAccount(userWalletId: UserWalletId): ArchivedAccount {
val derivationIndex = DerivationIndex(value = 1000).getOrNull()!!
private suspend fun getAccountsResponseSync(userWalletId: UserWalletId): GetWalletAccountsResponse? {
val store = getAccountsResponseStore(userWalletId = userWalletId)
return store.data.firstOrNull()
}
return ArchivedAccount(
accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = derivationIndex,
),
name = AccountName("Archived Account").getOrNull()!!,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
derivationIndex = derivationIndex,
tokensCount = 2,
networksCount = 1,
)
private fun getAccountsResponseStore(userWalletId: UserWalletId): AccountsResponseStore {
return accountsResponseStoreFactory.create(userWalletId = userWalletId)
}
private fun getArchivedAccountsStore(userWalletId: UserWalletId): ArchivedAccountsStore {
return archivedAccountsStoreFactory.create(userWalletId)
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.data.account.store
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
typealias AccountsResponseStore = DataStore<GetWalletAccountsResponse?>
/**
* Factory class for creating and managing instances of [AccountsResponseStore].
* This class is responsible for creating a [DataStore] for each unique [UserWalletId].
*
* @property context application context used to access the file system
* @property moshi moshi instance for JSON serialization and deserialization
* @property dispatchers coroutine dispatcher provider
*
[REDACTED_AUTHOR]
*/
internal class AccountsResponseStoreFactory @Inject constructor(
@ApplicationContext private val context: Context,
@NetworkMoshi private val moshi: Moshi,
private val dispatchers: CoroutineDispatcherProvider,
) {
@OptIn(ExperimentalStdlibApi::class)
private val adapter by lazy { moshi.adapter<GetWalletAccountsResponse?>() }
private val createdDataStores = ConcurrentHashMap<UserWalletId, AccountsResponseStore>()
/**
* Creates or retrieves an [AccountsResponseStore] for the given [UserWalletId].
*
* @param userWalletId the unique identifier of the user's wallet
*/
fun create(userWalletId: UserWalletId): AccountsResponseStore {
return createdDataStores.computeIfAbsent(userWalletId) {
DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(defaultValue = null, adapter = adapter),
produceFile = { context.dataStoreFile(fileName = "wallet_accounts_${userWalletId.stringValue}") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
}
}
@VisibleForTesting
fun getAllStores(): Map<UserWalletId, AccountsResponseStore> = createdDataStores.toMap()
@VisibleForTesting
fun clearStores() {
createdDataStores.clear()
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.data.account.store
import androidx.annotation.VisibleForTesting
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.account.models.ArchivedAccount
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlin.time.Duration.Companion.seconds
/**
* Store for managing archived accounts with support for data expiration
*
* @property runtimeStore the underlying runtime shared store for storing the list of archived accounts
*
[REDACTED_AUTHOR]
*/
internal class ArchivedAccountsStore(
private val runtimeStore: RuntimeStateStore<List<ArchivedAccount>?>,
) {
private var timestamp: Long? = null
/** Retrieves a flow of archived accounts, filtering out null values */
fun get(): Flow<List<ArchivedAccount>> {
return runtimeStore.get()
.map {
if (isDataExpired()) null else it
}
.filterNotNull()
}
/** Retrieves the list of archived accounts synchronously, or null if the data is expired */
suspend fun getSyncOrNull(): List<ArchivedAccount>? {
if (isDataExpired()) return null
return runtimeStore.getSyncOrNull()
}
/** Stores the provided list of archived accounts [value] */
suspend fun store(value: List<ArchivedAccount>) {
timestamp = System.currentTimeMillis()
runtimeStore.store(value)
}
private fun isDataExpired(): Boolean {
val currentTime = System.currentTimeMillis()
val storedTime = timestamp ?: return true
return currentTime - storedTime >= EXPIRATION_DURATION_MS
}
@VisibleForTesting
fun setTimestamp(time: Long) {
timestamp = time
}
@VisibleForTesting
fun clear() {
timestamp = null
runtimeStore.clear()
}
private companion object Companion {
val EXPIRATION_DURATION_MS = 120.seconds.inWholeMicroseconds
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.data.account.store
import androidx.annotation.VisibleForTesting
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.models.wallet.UserWalletId
import java.util.concurrent.ConcurrentHashMap
/**
* Factory for creating and managing instances of [ArchivedAccountsStore].
* and reused for each unique [UserWalletId].
*
[REDACTED_AUTHOR]
*/
internal object ArchivedAccountsStoreFactory {
private val createdRuntimeStores = ConcurrentHashMap<UserWalletId, ArchivedAccountsStore>()
/**
* Creates or retrieves an existing instance of [ArchivedAccountsStore] for the given [userWalletId].
*
* @param userWalletId the unique identifier for the user wallet
*/
fun create(userWalletId: UserWalletId): ArchivedAccountsStore {
return createdRuntimeStores.computeIfAbsent(userWalletId) {
ArchivedAccountsStore(runtimeStore = RuntimeStateStore(defaultValue = null))
}
}
@VisibleForTesting
fun getAllStores(): Map<UserWalletId, ArchivedAccountsStore> = createdRuntimeStores.toMap()
@VisibleForTesting
fun clearStores() {
createdRuntimeStores.clear()
}
}

View file

@ -0,0 +1,85 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
internal fun createWalletAccountDTO(
userWalletId: UserWalletId,
accountId: String? = null,
accountName: String? = null,
icon: String? = null,
iconColor: String? = null,
derivationIndex: Int? = null,
tokens: List<UserTokensResponse.Token>? = emptyList(),
): WalletAccountDTO {
val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)
return WalletAccountDTO(
id = accountId ?: mainAccount.accountId.value,
name = accountName ?: mainAccount.accountName.value,
derivationIndex = derivationIndex ?: mainAccount.derivationIndex.value,
icon = icon ?: mainAccount.icon.value.name,
iconColor = iconColor ?: mainAccount.icon.color.name,
tokens = tokens,
)
}
internal fun createCryptoPortfolio(userWalletId: UserWalletId): Account.CryptoPortfolio {
return Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)
}
internal fun createGetWalletAccountsResponse(
userWalletId: UserWalletId,
groupType: UserTokensResponse.GroupType = UserTokensResponse.GroupType.NETWORK,
sortType: UserTokensResponse.SortType = UserTokensResponse.SortType.BALANCE,
accountId: String? = null,
accountName: String? = null,
icon: String? = null,
iconColor: String? = null,
derivationIndex: Int? = null,
tokens: List<UserTokensResponse.Token>? = emptyList(),
): GetWalletAccountsResponse {
return GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = 0,
group = groupType,
sort = sortType,
totalAccounts = 1,
),
accounts = buildList {
createWalletAccountDTO(
userWalletId = userWalletId,
accountId = accountId,
accountName = accountName,
icon = icon,
iconColor = iconColor,
derivationIndex = derivationIndex,
tokens = tokens,
)
.let(::add)
},
unassignedTokens = emptyList(),
)
}
internal fun createAccountList(
userWallet: UserWallet,
sortType: TokensSortType = TokensSortType.BALANCE,
groupType: TokensGroupType = TokensGroupType.NETWORK,
): AccountList {
return AccountList(
userWallet = userWallet,
accounts = setOf(createCryptoPortfolio(userWallet.walletId)),
totalAccounts = 1,
sortType = sortType,
groupType = groupType,
)
.getOrNull()!!
}

View file

@ -0,0 +1,159 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class AccountListConverterTest {
private val userWallet = mockk<UserWallet> {
every { walletId } returns UserWalletId("011")
}
private val cryptoPortfolioConverterFactory = mockk<CryptoPortfolioConverter.Factory>()
private val cryptoPortfolioConverter = mockk<CryptoPortfolioConverter>()
private val converter = AccountListConverter(userWallet, cryptoPortfolioConverterFactory)
@BeforeAll
fun setupAll() {
every { cryptoPortfolioConverterFactory.create(userWallet) } returns cryptoPortfolioConverter
}
@BeforeEach
fun setupEach() {
clearMocks(cryptoPortfolioConverter)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@Test
fun `cryptoPortfolioConverter throws exception`() {
// Arrange
val dto = createGetWalletAccountsResponse(userWallet.walletId)
val exception = IllegalStateException("Test exception")
every { cryptoPortfolioConverter.convert(any()) } throws exception
// Act
val actual = runCatching { converter.convert(dto) }.exceptionOrNull()!!
// Asset
val expected = exception
Truth.assertThat(actual).isInstanceOf(expected::class.java)
Truth.assertThat(actual.message).isEqualTo(expected.message)
}
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Arrange
if (model.expected.isSuccess) {
model.value.accounts.forEach { dto ->
val account = model.expected.getOrNull()!!.accounts
.firstOrNull { it.accountId.value == dto.id } as? Account.CryptoPortfolio
every { cryptoPortfolioConverter.convert(dto) } returns account!!
}
}
// Act
val actual = runCatching { converter.convert(model.value) }
// Asset
actual
.onSuccess {
val expected = model.expected.getOrNull()
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull() ?: throw it
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<ConvertModel> {
return listOf(
ConvertModel(
value = createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.BALANCE,
groupType = UserTokensResponse.GroupType.NETWORK,
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
sortType = TokensSortType.BALANCE,
groupType = TokensGroupType.NETWORK,
),
),
),
ConvertModel(
value = createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.MANUAL,
groupType = UserTokensResponse.GroupType.TOKEN,
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),
),
),
ConvertModel(
value = createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.MARKETCAP,
groupType = UserTokensResponse.GroupType.NONE,
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),
),
),
ConvertModel(
value = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
totalAccounts = 1,
),
accounts = emptyList(),
unassignedTokens = emptyList(),
),
expected = Result.failure(
IllegalStateException(
"Failed to convert GetWalletAccountsResponse to AccountList: EmptyAccountsList: " +
"The accounts list cannot be empty",
),
),
),
)
}
}
data class ConvertModel(
val value: GetWalletAccountsResponse,
val expected: Result<AccountList>,
)
}

View file

@ -0,0 +1,144 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ArchivedAccountConverterTest {
private val userWalletId = UserWalletId("011")
private val converter = ArchivedAccountConverter(userWalletId = userWalletId)
@ParameterizedTest
@ProvideTestModels
fun convert(model: TestModel) {
// Act
val actual = runCatching { converter.convert(value = model.value) }
// Assert
actual
.onSuccess {
val expected = model.expected.getOrNull()!!
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull()!!
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<TestModel> {
return listOf(
TestModel(
value = createDTO(),
expected = Result.success(createDomain()),
),
TestModel(
value = createDTO(accountId = "123"),
expected = Result.failure(
IllegalStateException(
"Unable to create AccountId from value: 123. Cause: ${AccountId.Error.InvalidFormat}",
),
),
),
TestModel(
value = createDTO(name = ""),
expected = Result.failure(
IllegalStateException(
"Unable to create AccountName from value: . Cause: ${AccountName.Error.Empty}",
),
),
),
TestModel(
value = createDTO(icon = "INVALID_ICON"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON",
),
),
),
TestModel(
value = createDTO(iconColor = "INVALID_COLOR"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR",
),
),
),
TestModel(
value = createDTO(derivationIndex = -1),
expected = Result.failure(
IllegalStateException(
"Unable to create DerivationIndex from value: -1. " +
"Cause: NegativeDerivationIndex: Derivation index cannot be negative: -1",
),
),
),
TestModel(
value = createDTO(totalTokens = null),
expected = Result.failure(
IllegalStateException("Total tokens should not be null"),
),
),
TestModel(
value = createDTO(totalNetworks = null),
expected = Result.failure(
IllegalStateException("Total networks should not be null"),
),
),
)
}
private fun createDTO(
accountId: String = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027",
name: String = "Test Account",
icon: String = "Letter",
iconColor: String = "Azure",
derivationIndex: Int = 0,
totalTokens: Int? = 1,
totalNetworks: Int? = 1,
): WalletAccountDTO {
return WalletAccountDTO(
id = accountId,
name = name,
derivationIndex = derivationIndex,
icon = icon,
iconColor = iconColor,
tokens = null,
totalTokens = totalTokens,
totalNetworks = totalNetworks,
)
}
private fun createDomain(): ArchivedAccount {
return ArchivedAccount(
accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex(0).getOrNull()!!),
name = "Test Account".toAccountName(),
derivationIndex = 0.toDerivationIndex(),
icon = CryptoPortfolioIcon.ofCustomAccount(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
tokensCount = 1,
networksCount = 1,
)
}
data class TestModel(
val value: WalletAccountDTO,
val expected: Result<ArchivedAccount>,
)
}

View file

@ -0,0 +1,155 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CryptoPortfolioConverterTest {
private val userWallet = mockk<UserWallet> {
every { walletId } returns UserWalletId("011")
}
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk()
private val userTokensResponseFactory: UserTokensResponseFactory = mockk()
private val converter = CryptoPortfolioConverter(
userWallet = userWallet,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
userTokensResponseFactory = userTokensResponseFactory,
)
@BeforeEach
fun setupEach() {
clearMocks(responseCryptoCurrenciesFactory, userTokensResponseFactory)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = runCatching { converter.convert(model.value) }
// Asset
actual
.onSuccess {
val expected = model.expected.getOrNull()
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull() ?: throw it
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<ConvertModel> {
return listOf(
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId),
expected = Result.success(createCryptoPortfolio(userWalletId = userWallet.walletId)),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountId = "123"),
expected = Result.failure(
IllegalStateException(
"Unable to create AccountId from value: 123. Cause: ${AccountId.Error.InvalidFormat}",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountName = ""),
expected = Result.failure(
IllegalStateException(
"Unable to create AccountName from value: . Cause: ${AccountName.Error.Empty}",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, icon = "INVALID_ICON"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, iconColor = "INVALID_COLOR"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, derivationIndex = -1),
expected = Result.failure(
IllegalStateException(
"Unable to create DerivationIndex from value: -1. " +
"Cause: NegativeDerivationIndex: Derivation index cannot be negative: -1",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, tokens = null),
expected = Result.failure(
IllegalStateException("Tokens should not be null"),
),
),
)
}
}
data class ConvertModel(
val value: WalletAccountDTO,
val expected: Result<Account.CryptoPortfolio>,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ConvertBack {
@ParameterizedTest
@ProvideTestModels
fun convertBack(model: ConvertBackModel) {
// Act
val actual = converter.convertBack(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
private fun provideTestModels(): List<ConvertBackModel> {
return listOf(
ConvertBackModel(
value = createCryptoPortfolio(userWalletId = userWallet.walletId),
expected = createWalletAccountDTO(userWalletId = userWallet.walletId),
),
)
}
}
data class ConvertBackModel(
val value: Account.CryptoPortfolio,
val expected: WalletAccountDTO,
)
}

View file

@ -0,0 +1,66 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.data.account.converter.CryptoPortfolioIconConverter.DataModel
import com.tangem.domain.models.account.CryptoPortfolioIcon
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class CryptoPortfolioIconConverterTest {
@ParameterizedTest
@ProvideTestModels
fun convert(model: TestModel) {
// Act
val actual = runCatching { CryptoPortfolioIconConverter.convert(model.value) }
// Assert
actual
.onSuccess {
val expected = model.expected.getOrNull()!!
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull()!!
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<TestModel> {
return listOf(
TestModel(
value = DataModel(icon = "Letter", color = "Azure"),
expected = Result.success(
CryptoPortfolioIcon.ofCustomAccount(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
),
),
TestModel(
value = DataModel(icon = "INVALID_ICON", color = "Azure"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON",
),
),
),
TestModel(
value = DataModel(icon = "Letter", color = "INVALID_COLOR"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR",
),
),
),
)
}
data class TestModel(
val value: DataModel,
val expected: Result<CryptoPortfolioIcon>,
)
}

View file

@ -0,0 +1,130 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetWalletAccountsResponseConverterTest {
private val userWallet = mockk<UserWallet> {
every { walletId } returns UserWalletId("011")
}
private val cryptoPortfolioConverterFactory = mockk<CryptoPortfolioConverter.Factory>()
private val cryptoPortfolioConverter = mockk<CryptoPortfolioConverter>()
private val converter = GetWalletAccountsResponseConverter(
userWallet = userWallet,
version = 0,
cryptoPortfolioConverterFactory = cryptoPortfolioConverterFactory,
)
@BeforeAll
fun setupAll() {
every { cryptoPortfolioConverterFactory.create(userWallet) } returns cryptoPortfolioConverter
}
@BeforeEach
fun setupEach() {
clearMocks(cryptoPortfolioConverter)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@Test
fun `cryptoPortfolioConverter throws exception`() {
// Arrange
val domain = createAccountList(userWallet = userWallet)
val exception = IllegalStateException("Test exception")
every { cryptoPortfolioConverter.convertBack(any()) } throws exception
// Act
val actual = runCatching { converter.convert(domain) }.exceptionOrNull()!!
// Asset
val expected = exception
Truth.assertThat(actual).isInstanceOf(expected::class.java)
Truth.assertThat(actual.message).isEqualTo(expected.message)
}
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Arrange
if (model.expected.isSuccess) {
model.value.accounts.forEach { domain ->
val dto = model.expected.getOrNull()!!.accounts.firstOrNull { it.id == domain.accountId.value }
every { cryptoPortfolioConverter.convertBack(domain as Account.CryptoPortfolio) } returns dto!!
}
}
// Act
val actual = runCatching { converter.convert(model.value) }
// Asset
actual
.onSuccess {
val expected = model.expected.getOrNull()
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull() ?: throw it
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<ConvertModel> {
return listOf(
ConvertModel(
value = createAccountList(
userWallet = userWallet,
sortType = TokensSortType.BALANCE,
groupType = TokensGroupType.NETWORK,
),
expected = Result.success(
createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.BALANCE,
groupType = UserTokensResponse.GroupType.NETWORK,
),
),
),
ConvertModel(
value = createAccountList(
userWallet = userWallet,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),
expected = Result.success(
createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.MANUAL,
groupType = UserTokensResponse.GroupType.NONE,
),
),
),
)
}
}
data class ConvertModel(
val value: AccountList,
val expected: Result<GetWalletAccountsResponse>,
)
}

View file

@ -0,0 +1,50 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SaveWalletAccountsResponseConverterTest {
@Test
fun convert() {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns UserWalletId("011")
}
val accountList = AccountList(
userWallet = userWallet,
accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWallet.walletId)),
totalAccounts = 1,
)
.getOrNull()!!
// Act
val actual = SaveWalletAccountsResponseConverter.convert(value = accountList)
// Assert
val expected = SaveWalletAccountsResponse(
accounts = listOf(
WalletAccountDTO(
id = accountList.mainAccount.accountId.value,
name = accountList.mainAccount.accountName.value,
derivationIndex = accountList.mainAccount.derivationIndex.value,
icon = accountList.mainAccount.icon.value.name,
iconColor = accountList.mainAccount.icon.color.name,
),
),
)
Truth.assertThat(actual).isEqualTo(expected)
}
}

View file

@ -0,0 +1,85 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.TokensGroupType
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TokensGroupTypeConverterTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = TokensGroupTypeConverter.convert(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
private fun provideTestModels(): List<ConvertModel> {
return listOf(
ConvertModel(
value = UserTokensResponse.GroupType.NETWORK,
expected = TokensGroupType.NETWORK,
),
ConvertModel(
value = UserTokensResponse.GroupType.NONE,
expected = TokensGroupType.NONE,
),
ConvertModel(
value = UserTokensResponse.GroupType.TOKEN,
expected = TokensGroupType.NONE,
),
)
}
}
data class ConvertModel(
val value: UserTokensResponse.GroupType,
val expected: TokensGroupType,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ConvertBack {
@ParameterizedTest
@ProvideTestModels
fun convertBack(model: ConvertBackModel) {
// Act
val actual = TokensGroupTypeConverter.convertBack(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
private fun provideTestModels(): List<ConvertBackModel> {
return listOf(
ConvertBackModel(
value = TokensGroupType.NETWORK,
expected = UserTokensResponse.GroupType.NETWORK,
),
ConvertBackModel(
value = TokensGroupType.NONE,
expected = UserTokensResponse.GroupType.NONE,
),
)
}
}
data class ConvertBackModel(
val value: TokensGroupType,
val expected: UserTokensResponse.GroupType,
)
}

View file

@ -0,0 +1,81 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.TokensSortType
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TokensSortTypeConverterTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = TokensSortTypeConverter.convert(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
fun provideTestModels() = listOf(
ConvertModel(
value = UserTokensResponse.SortType.BALANCE,
expected = TokensSortType.BALANCE,
),
ConvertModel(
value = UserTokensResponse.SortType.MANUAL,
expected = TokensSortType.NONE,
),
ConvertModel(
value = UserTokensResponse.SortType.MARKETCAP,
expected = TokensSortType.NONE,
),
)
}
data class ConvertModel(
val value: UserTokensResponse.SortType,
val expected: TokensSortType,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ConvertBack {
@ParameterizedTest
@ProvideTestModels
fun convertBack(model: ConvertBackModel) {
// Act
val actual = TokensSortTypeConverter.convertBack(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
fun provideTestModels() = listOf(
ConvertBackModel(
value = TokensSortType.BALANCE,
expected = UserTokensResponse.SortType.BALANCE,
),
ConvertBackModel(
value = TokensSortType.NONE,
expected = UserTokensResponse.SortType.MANUAL,
),
)
}
data class ConvertBackModel(
val value: TokensSortType,
val expected: UserTokensResponse.SortType,
)
}

View file

@ -0,0 +1,664 @@
package com.tangem.data.account.repository
import arrow.core.None
import arrow.core.toOption
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.account.converter.*
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.ArchivedAccountsStore
import com.tangem.data.account.store.ArchivedAccountsStoreFactory
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletArchivedAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.models.account.Account.CryptoPortfolio
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
import kotlin.time.Duration.Companion.minutes
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultAccountsCRUDRepositoryTest {
private val tangemTechApi: TangemTechApi = mockk()
private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk()
private val accountsResponseStore: AccountsResponseStore = mockk()
private val accountsResponseStoreFlow = MutableStateFlow<GetWalletAccountsResponse?>(value = null)
private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory = mockk()
private val archivedAccountsInnerStore = RuntimeStateStore<List<ArchivedAccount>?>(defaultValue = null)
private val archivedAccountsStore = ArchivedAccountsStore(runtimeStore = archivedAccountsInnerStore)
private val userWalletsStore: UserWalletsStore = mockk()
private val convertersContainer: AccountConverterFactoryContainer = mockk()
private val accountListConverter: AccountListConverter = mockk()
private val cryptoPortfolioConverter: CryptoPortfolioConverter = mockk()
private val repository = DefaultAccountsCRUDRepository(
tangemTechApi = tangemTechApi,
accountsResponseStoreFactory = accountsResponseStoreFactory,
archivedAccountsStoreFactory = archivedAccountsStoreFactory,
userWalletsStore = userWalletsStore,
convertersContainer = convertersContainer,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val userWalletId = UserWalletId("011")
@BeforeAll
fun setup() {
every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore
every { accountsResponseStore.data } returns accountsResponseStoreFlow
every { convertersContainer.createAccountListConverter(userWalletId) } returns accountListConverter
every { convertersContainer.createCryptoPortfolioConverter(userWalletId) } returns cryptoPortfolioConverter
}
@BeforeEach
fun setupEach() {
every { archivedAccountsStoreFactory.create(userWalletId) } returns archivedAccountsStore
}
@AfterEach
fun tearDownEach() {
accountsResponseStoreFlow.value = null
archivedAccountsInnerStore.clear()
clearMocks(
tangemTechApi,
archivedAccountsStoreFactory,
accountListConverter,
cryptoPortfolioConverter,
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetAccountListSync {
@Test
fun `getAccounts should return None when account list response is null`() = runTest {
// Arrange
accountsResponseStoreFlow.value = null
// Act
val actual = repository.getAccountListSync(userWalletId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
verify(inverse = true) { accountListConverter.convert(value = any()) }
}
@Test
fun `getAccounts should return AccountList when account list response is not null`() = runTest {
// Arrange
val response = mockk<GetWalletAccountsResponse>()
val accountList = mockk<AccountList>()
accountsResponseStoreFlow.value = response
every { accountListConverter.convert(response) } returns accountList
// Act
val actual = repository.getAccountListSync(userWalletId)
// Assert
val expected = accountList.toOption()
Truth.assertThat(actual).isEqualTo(expected)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
convertersContainer.createAccountListConverter(userWalletId = userWalletId)
accountListConverter.convert(response)
}
}
@Test
fun `getAccounts should throw exception if converter throws exception`() = runTest {
// Arrange
val response = mockk<GetWalletAccountsResponse>()
mockk<AccountList>()
accountsResponseStoreFlow.value = response
val exception = Exception("Test error")
every { accountListConverter.convert(response) } throws exception
// Act
val actual = runCatching { repository.getAccountListSync(userWalletId) }.exceptionOrNull()!!
// Assert
val expected = exception
Truth.assertThat(actual).isSameInstanceAs(expected)
Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
convertersContainer.createAccountListConverter(userWalletId = userWalletId)
accountListConverter.convert(response)
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetAccountSync {
private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main)
@Test
fun `getAccount should return None when account response is null`() = runTest {
// Arrange
val response = null
accountsResponseStoreFlow.value = response
// Act
val actual = repository.getAccountSync(accountId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
verify(inverse = true) { convertersContainer.createCryptoPortfolioConverter(userWalletId = any()) }
}
@Test
fun `getAccount should return None when accountDto is not found`() = runTest {
// Arrange
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.accounts } returns emptyList()
}
accountsResponseStoreFlow.value = response
// Act
val actual = repository.getAccountSync(accountId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
verify(inverse = true) { convertersContainer.createCryptoPortfolioConverter(userWalletId = any()) }
}
@Test
fun `getAccount should return Account_CryptoPortfolio when account response is not null`() = runTest {
// Arrange
val accountDTO = mockk<WalletAccountDTO> {
every { this@mockk.id } returns accountId.value
}
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.accounts } returns listOf(accountDTO)
}
accountsResponseStoreFlow.value = response
val cryptoPortfolio = mockk<CryptoPortfolio>()
every { cryptoPortfolioConverter.convert(accountDTO) } returns cryptoPortfolio
// Act
val actual = repository.getAccountSync(accountId)
// Assert
val expected = cryptoPortfolio.toOption()
Truth.assertThat(actual).isEqualTo(expected)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
convertersContainer.createCryptoPortfolioConverter(userWalletId)
cryptoPortfolioConverter.convert(accountDTO)
}
}
@Test
fun `getAccount should throw exception if converter throws exception`() = runTest {
// Arrange
val accountDTO = mockk<WalletAccountDTO> {
every { this@mockk.id } returns accountId.value
}
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.accounts } returns listOf(accountDTO)
}
accountsResponseStoreFlow.value = response
val exception = Exception("Test error")
every { cryptoPortfolioConverter.convert(accountDTO) } throws exception
// Act
val actual = runCatching { repository.getAccountSync(accountId) }.exceptionOrNull()!!
// Assert
val expected = exception
Truth.assertThat(actual).isSameInstanceAs(expected)
Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
convertersContainer.createCryptoPortfolioConverter(userWalletId)
cryptoPortfolioConverter.convert(accountDTO)
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetArchivedAccountSync {
private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main)
@Test
fun `getArchivedAccount should return None when archived accounts are null`() = runTest {
// Arrange
archivedAccountsInnerStore.store(value = null)
// Act
val actual = repository.getArchivedAccountSync(accountId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccount should return None when archived account not found`() = runTest {
// Arrange
archivedAccountsInnerStore.store(value = listOf())
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds)
// Act
val actual = repository.getArchivedAccountSync(accountId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccount should return ArchivedAccount when found`() = runTest {
// Arrange
val archivedAccount = ArchivedAccount(
accountId = accountId,
name = AccountName("Archived Account").getOrNull()!!,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
derivationIndex = DerivationIndex.Main,
tokensCount = 0,
networksCount = 0,
)
archivedAccountsInnerStore.store(value = listOf(archivedAccount))
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds)
// Act
val actual = repository.getArchivedAccountSync(accountId)
// Assert
val expected = archivedAccount.toOption()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccount should throws exception when store throws exception`() = runTest {
// Arrange
val exception = Exception("Test error")
coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception
// Act
val actual = runCatching { repository.getArchivedAccountSync(accountId) }.exceptionOrNull()!!
// Assert
val expected = exception
Truth.assertThat(actual).isSameInstanceAs(expected)
Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message)
coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetArchivedAccountListSync {
@Test
fun `getArchivedAccountListSync should return None when archived accounts are null`() = runTest {
// Arrange
archivedAccountsInnerStore.store(value = null)
// Act
val actual = repository.getArchivedAccountListSync(userWalletId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccountListSync should return Option with list when archived accounts exist`() = runTest {
// Arrange
val archivedAccount1 = mockk<ArchivedAccount>()
val archivedAccount2 = mockk<ArchivedAccount>()
val archivedAccounts = listOf(archivedAccount1, archivedAccount2)
archivedAccountsInnerStore.store(value = archivedAccounts)
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds)
// Act
val actual = repository.getArchivedAccountListSync(userWalletId)
// Assert
val expected = archivedAccounts.toOption()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccountListSync should throw exception when store throws exception`() = runTest {
// Arrange
val exception = Exception("Test error")
coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception
// Act
val actual = runCatching { repository.getArchivedAccountListSync(userWalletId) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isSameInstanceAs(exception)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetArchivedAccounts {
@Test
fun `getArchivedAccounts should emit empty list when no archived accounts`() = runTest {
// Arrange
archivedAccountsInnerStore.store(value = null)
val archivedAccountsFlow = repository.getArchivedAccounts(userWalletId)
// Act
val actual = getEmittedValues(archivedAccountsFlow)
// Assert
Truth.assertThat(actual).isEmpty()
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.get()
}
}
@Test
fun `getArchivedAccounts should emit list of archived accounts when present`() = runTest {
// Arrange
val archivedAccount1 = mockk<ArchivedAccount>()
val archivedAccount2 = mockk<ArchivedAccount>()
val archivedAccounts = listOf(archivedAccount1, archivedAccount2)
archivedAccountsInnerStore.store(value = archivedAccounts)
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds)
val archivedAccountsFlow = repository.getArchivedAccounts(userWalletId)
// Act
val actual = getEmittedValues(archivedAccountsFlow)
// Assert
val expected = listOf(archivedAccounts)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.get()
}
}
@Test
fun `getArchivedAccounts should throw exception when store throws exception`() = runTest {
// Arrange
val exception = Exception("Test error")
coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception
// Act
val actual = runCatching { repository.getArchivedAccounts(userWalletId) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isSameInstanceAs(exception)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class FetchArchivedAccounts {
private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main)
@Test
fun `fetchArchivedAccounts should store archived accounts in store`() = runTest {
// Arrange
val accountDTO = WalletAccountDTO(
id = accountId.value,
name = "Archived Account",
derivationIndex = 0,
icon = CryptoPortfolioIcon.Icon.Wallet.name,
iconColor = CryptoPortfolioIcon.Color.DullLavender.name,
totalNetworks = 0,
totalTokens = 0,
)
val apiResponse = mockk<GetWalletArchivedAccountsResponse> {
every { this@mockk.accounts } returns listOf(accountDTO)
}
val archivedAccount = ArchivedAccountConverter(userWalletId).convert(accountDTO)
coEvery {
tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue)
} returns ApiResponse.Success(apiResponse)
// Act
repository.fetchArchivedAccounts(userWalletId)
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
Truth.assertThat(actual).containsExactly(archivedAccount)
coVerifyOrder {
tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue)
archivedAccountsStoreFactory.create(userWalletId)
}
}
@Test
fun `fetchArchivedAccounts should throw exception if API returns error`() = runTest { // Arrange
val exception = Exception("API error")
coEvery { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) } throws exception
// Act
val actual = runCatching { repository.fetchArchivedAccounts(userWalletId) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isSameInstanceAs(exception)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
Truth.assertThat(archivedAccountsStore.getSyncOrNull()).isNull()
coVerify { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class SaveAccounts {
private val version = 1
@Test
fun `saveAccounts should call API and update store`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
}
val accountList = AccountList.empty(userWallet = userWallet)
val accountsResponse = mockk<GetWalletAccountsResponse> {
every { this@mockk.wallet.version } returns version
}
accountsResponseStoreFlow.value = accountsResponse
val body = SaveWalletAccountsResponseConverter.convert(value = accountList)
val apiResponse = ApiResponse.Success(Unit)
coEvery {
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
ifMatch = version.toString(),
body = body,
)
} returns apiResponse
val converter = mockk<GetWalletAccountsResponseConverter> {
every { this@mockk.convert(accountList) } returns accountsResponse
}
every {
convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet, version = version)
} returns converter
coEvery { accountsResponseStore.updateData(transform = any()) } returns accountsResponse
// Act
repository.saveAccounts(accountList)
// Assert
Truth.assertThat(accountsResponseStoreFlow.value).isEqualTo(accountsResponse)
coVerifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
tangemTechApi.saveWalletAccounts(userWalletId.stringValue, version.toString(), body)
convertersContainer.getWalletAccountsResponseCF.create(userWallet, version)
converter.convert(accountList)
accountsResponseStore.updateData(any())
}
}
@Test
fun `saveAccounts if API request is failed`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
}
val accountList = AccountList.empty(userWallet = userWallet)
val accountsResponse = mockk<GetWalletAccountsResponse> {
every { this@mockk.wallet.version } returns version
}
accountsResponseStoreFlow.value = accountsResponse
val body = SaveWalletAccountsResponseConverter.convert(value = accountList)
val apiResponse = ApiResponse.Error(cause = ApiResponseError.NetworkException) as ApiResponse<Unit>
coEvery {
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
ifMatch = version.toString(),
body = body,
)
} returns apiResponse
// Act
val actual = runCatching { repository.saveAccounts(accountList) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isEqualTo(ApiResponseError.NetworkException)
coVerifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
tangemTechApi.saveWalletAccounts(userWalletId.stringValue, version.toString(), body)
}
coVerify(inverse = true) {
convertersContainer.getWalletAccountsResponseCF.create(any(), any())
accountsResponseStore.updateData(any())
}
}
}
}

View file

@ -0,0 +1,91 @@
package com.tangem.data.account.store
import android.content.Context
import com.google.common.truth.Truth
import com.squareup.moshi.Moshi
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.mockk
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class AccountsResponseStoreFactoryTest {
private val context: Context = mockk()
private val moshi: Moshi = Moshi.Builder().build()
private val factory: AccountsResponseStoreFactory = AccountsResponseStoreFactory(
context = context,
moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@AfterEach
fun setup() {
clearMocks(context)
factory.clearStores()
}
@Test
fun `creates new data store for unique userWalletId`() {
// Arrange
val userWalletId = UserWalletId("011")
val createdStore = factory.create(userWalletId = userWalletId)
// Actual
val actual = factory.getAllStores()
// Assert
Truth.assertThat(actual).containsExactly(userWalletId, createdStore)
}
@Test
fun `reuses existing data store for same userWalletId`() {
val userWalletId = UserWalletId("011")
// Arrange (first creation)
val firstStore = factory.create(userWalletId = userWalletId)
// Act (first creation)
val actual1 = factory.getAllStores()
// Assert (first creation)
Truth.assertThat(actual1).containsExactly(userWalletId, firstStore)
// Arrange (second creation)
val secondStore = factory.create(userWalletId = userWalletId)
// Act (second creation)
val actual2 = factory.getAllStores()
// Assert (second creation)
Truth.assertThat(actual2).containsExactly(userWalletId, secondStore)
Truth.assertThat(firstStore).isSameInstanceAs(secondStore)
}
@Test
fun `creates separate data stores for different userWalletIds`() {
// Arrange (first creation)
val firstWalletId = UserWalletId("011")
val firstStore = factory.create(userWalletId = firstWalletId)
// Act (first creation)
val actual1 = factory.getAllStores()
// Assert (first creation)
Truth.assertThat(actual1).containsExactly(firstWalletId, firstStore)
// Arrange (second creation)
val secondWalletId = UserWalletId("011")
val secondStore = factory.create(userWalletId = secondWalletId)
// Act (second creation)
val actual2 = factory.getAllStores()
// Assert (second creation)
val expected = mapOf(firstWalletId to firstStore, secondWalletId to secondStore)
Truth.assertThat(actual2).containsExactlyEntriesIn(expected)
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.data.account.store
import com.google.common.truth.Truth
import com.tangem.domain.models.wallet.UserWalletId
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ArchivedAccountsStoreFactoryTest {
private val factory = ArchivedAccountsStoreFactory
@AfterEach
fun tearDownEach() {
factory.clearStores()
}
@Test
fun `creates new store for unique userWalletId`() {
// Arrange
val userWalletId = UserWalletId("001")
val createdStore = factory.create(userWalletId)
// Act
val actual = factory.getAllStores()
// Assert
Truth.assertThat(actual).containsExactly(userWalletId, createdStore)
}
@Test
fun `reuses existing data store for same userWalletId`() {
val userWalletId = UserWalletId("011")
// Arrange (first creation)
val firstStore = factory.create(userWalletId = userWalletId)
// Act (first creation)
val actual1 = factory.getAllStores()
// Assert (first creation)
Truth.assertThat(actual1).containsExactly(userWalletId, firstStore)
// Arrange (second creation)
val secondStore = factory.create(userWalletId = userWalletId)
// Act (second creation)
val actual2 = factory.getAllStores()
// Assert (second creation)
Truth.assertThat(actual2).containsExactly(userWalletId, secondStore)
Truth.assertThat(firstStore).isSameInstanceAs(secondStore)
}
@Test
fun `creates separate data stores for different userWalletIds`() {
// Arrange (first creation)
val firstWalletId = UserWalletId("011")
val firstStore = factory.create(userWalletId = firstWalletId)
// Act (first creation)
val actual1 = factory.getAllStores()
// Assert (first creation)
Truth.assertThat(actual1).containsExactly(firstWalletId, firstStore)
// Arrange (second creation)
val secondWalletId = UserWalletId("011")
val secondStore = factory.create(userWalletId = secondWalletId)
// Act (second creation)
val actual2 = factory.getAllStores()
// Assert (second creation)
val expected = mapOf(firstWalletId to firstStore, secondWalletId to secondStore)
Truth.assertThat(actual2).containsExactlyEntriesIn(expected)
}
}

View file

@ -0,0 +1,139 @@
package com.tangem.data.account.store
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import kotlin.time.Duration.Companion.seconds
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ArchivedAccountsStoreTest {
private val runtimeStore: RuntimeStateStore<List<ArchivedAccount>?> = RuntimeStateStore(defaultValue = null)
private val archivedAccountsStore: ArchivedAccountsStore = ArchivedAccountsStore(runtimeStore = runtimeStore)
@AfterEach
fun tearDown() {
archivedAccountsStore.clear()
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Get {
@Test
fun `get returns empty flow`() = runTest {
// Act
val actual = getEmittedValues(archivedAccountsStore.get())
// Assert
Truth.assertThat(actual).isEmpty() // nothing emmited
}
@Test
fun `get returns flow with not expired data`() = runTest {
// Arrange
val archivedAccount = createArchivedAccount()
archivedAccountsStore.store(value = listOf(archivedAccount))
// Act
val actual = getEmittedValues(archivedAccountsStore.get())
// Assert
val expected = listOf(archivedAccount)
Truth.assertThat(actual).containsExactly(expected)
}
@Test
fun `get returns flow with expired data`() = runTest {
// Arrange
archivedAccountsStore.store(value = listOf(createArchivedAccount()))
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() - 120.seconds.inWholeMicroseconds)
// Act
val actual = getEmittedValues(archivedAccountsStore.get())
// Assert
Truth.assertThat(actual).isEmpty() // nothing emmited
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetSyncOrnNull {
@Test
fun `getSyncOrNull returns null`() = runTest {
// Act
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
Truth.assertThat(actual).isNull()
}
@Test
fun `get returns flow with not expired data`() = runTest {
// Arrange
val archivedAccount = createArchivedAccount()
archivedAccountsStore.store(value = listOf(archivedAccount))
// Act
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
val expected = archivedAccount
Truth.assertThat(actual).containsExactly(expected)
}
@Test
fun `get returns flow with expired data`() = runTest {
// Arrange
archivedAccountsStore.store(value = listOf(createArchivedAccount()))
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() - 120.seconds.inWholeMicroseconds)
// Act
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
Truth.assertThat(actual).isNull()
}
}
@Test
fun store() = runTest {
// Arrange
val archivedAccount = createArchivedAccount()
// Act
archivedAccountsStore.store(value = listOf(archivedAccount))
val actual = runtimeStore.getSyncOrNull()
// Assert
val expected = archivedAccount
Truth.assertThat(actual).containsExactly(expected)
}
private fun createArchivedAccount(): ArchivedAccount {
return ArchivedAccount(
accountId = AccountId.forCryptoPortfolio(
userWalletId = UserWalletId("011"),
derivationIndex = DerivationIndex.Main,
),
name = AccountName("Archived Account").getOrNull()!!,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
derivationIndex = DerivationIndex.Main,
tokensCount = 2,
networksCount = 1,
)
}
}

View file

@ -2,8 +2,9 @@ package com.tangem.data.common.currency
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.currency.CryptoCurrency
import javax.inject.Inject
class UserTokensResponseFactory {
class UserTokensResponseFactory @Inject constructor() {
fun createUserTokensResponse(
currencies: List<CryptoCurrency>,

View file

@ -37,6 +37,7 @@ import com.tangem.utils.coroutines.runCatching
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.collections.mutableSetOf
typealias SeedPhraseNotificationsStatuses = Map<UserWalletId, SeedPhraseNotificationsStatus>
@ -50,6 +51,9 @@ internal class DefaultWalletsRepository(
private val authProvider: AuthProvider,
) : WalletsRepository {
private val upgradeWalletNotificationDisabled: MutableStateFlow<Set<UserWalletId>> =
MutableStateFlow(mutableSetOf<UserWalletId>())
override suspend fun shouldSaveUserWalletsSync(): Boolean {
return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false)
}
@ -333,6 +337,16 @@ internal class DefaultWalletsRepository(
}
}
override fun isUpgradeWalletNotificationEnabled(userWalletId: UserWalletId): Flow<Boolean> {
return upgradeWalletNotificationDisabled.map {
it.contains(userWalletId)
}
}
override suspend fun dismissUpgradeWalletNotification(userWalletId: UserWalletId) {
upgradeWalletNotificationDisabled.update { it.plus(userWalletId) }
}
override suspend fun setWalletName(walletId: String, walletName: String) = withContext(dispatchers.io) {
tangemTechApi.updateWallet(
walletId = walletId,

View file

@ -11,6 +11,7 @@ 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.wallets.derivations.HotMapDerivationsRepository
import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.domain.wallets.usecase.BackendId
import com.tangem.hot.sdk.model.DeriveWalletRequest
import com.tangem.operations.derivation.ExtendedPublicKeysMap

View file

@ -3,7 +3,7 @@ package com.tangem.data.wallets.hot
import com.tangem.common.core.TangemSdkError
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.copy
import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.hot.sdk.TangemHotSdk
@ -11,22 +11,24 @@ import com.tangem.hot.sdk.exception.WrongPasswordException
import com.tangem.hot.sdk.model.*
import javax.inject.Inject
class HotWalletAccessor @Inject constructor(
class DefaultHotWalletAccessor @Inject constructor(
private val tangemHotSdk: TangemHotSdk,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletPasswordRequester: HotWalletPasswordRequester,
private val walletsRepository: WalletsRepository,
) {
) : HotWalletAccessor {
suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> =
override suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> =
hotSdkRequest(hotWalletId) { unlock ->
tangemHotSdk.signHashes(unlockHotWallet = unlock, dataToSign = dataToSign)
}
suspend fun derivePublicKeys(hotWalletId: HotWalletId, request: DeriveWalletRequest): DerivedPublicKeyResponse =
hotSdkRequest(hotWalletId) { unlock ->
tangemHotSdk.derivePublicKey(unlockHotWallet = unlock, request = request)
}
override suspend fun derivePublicKeys(
hotWalletId: HotWalletId,
request: DeriveWalletRequest,
): DerivedPublicKeyResponse = hotSdkRequest(hotWalletId) { unlock ->
tangemHotSdk.derivePublicKey(unlockHotWallet = unlock, request = request)
}
private suspend fun <T> hotSdkRequest(hotWalletId: HotWalletId, block: suspend (unlock: UnlockHotWallet) -> T): T {
val isAccessCodeRequired = walletsRepository.requireAccessCode()

View file

@ -6,6 +6,7 @@ import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.common.map
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.hot.sdk.model.DataToSign
import com.tangem.operations.sign.SignData
import dagger.assisted.Assisted

View file

@ -22,7 +22,7 @@ interface AccountsCRUDRepository {
* @param userWalletId the unique identifier of the user wallet
* @return an [Option] containing the [AccountList] if found, or `Option.None` if not
*/
suspend fun getAccounts(userWalletId: UserWalletId): Option<AccountList>
suspend fun getAccountListSync(userWalletId: UserWalletId): Option<AccountList>
/**
* Retrieves a specific account by its unique identifier
@ -30,14 +30,14 @@ interface AccountsCRUDRepository {
* @param accountId the unique identifier of the account
* @return an [Option] containing the [Account.CryptoPortfolio] if found, or `Option.None` if not
*/
suspend fun getAccount(accountId: AccountId): Option<Account.CryptoPortfolio>
suspend fun getAccountSync(accountId: AccountId): Option<Account.CryptoPortfolio>
/**
* Retrieves a archived account by its unique identifier
*
* @param accountId the unique identifier of the account
*/
suspend fun getArchivedAccount(accountId: AccountId): Option<ArchivedAccount>
suspend fun getArchivedAccountSync(accountId: AccountId): Option<ArchivedAccount>
/**
* Retrieves a list of archived accounts associated with a specific user wallet
@ -45,7 +45,7 @@ interface AccountsCRUDRepository {
* @param userWalletId the unique identifier of the user wallet
* @return an [Option] containing a list of [ArchivedAccount] if found, or `Option.None` if not
*/
suspend fun getArchivedAccountsSync(userWalletId: UserWalletId): Option<List<ArchivedAccount>>
suspend fun getArchivedAccountListSync(userWalletId: UserWalletId): Option<List<ArchivedAccount>>
/**
* Provides a flow of archived accounts associated with a specific user wallet
@ -73,7 +73,7 @@ interface AccountsCRUDRepository {
*
* @param userWalletId the unique identifier of the user wallet
*/
suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Int
suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Option<Int>
/**
* Retrieves a user wallet by its unique identifier

View file

@ -55,7 +55,7 @@ class AddCryptoPortfolioUseCase(
newAccount
}
private fun Raise<Error>.createAccount(
private fun createAccount(
userWalletId: UserWalletId,
accountName: AccountName,
icon: CryptoPortfolioIcon,
@ -72,7 +72,7 @@ class AddCryptoPortfolioUseCase(
private suspend fun Raise<Error>.getAccountList(userWalletId: UserWalletId): Option<AccountList> {
return catch(
block = { crudRepository.getAccounts(userWalletId = userWalletId) },
block = { crudRepository.getAccountListSync(userWalletId = userWalletId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
}

View file

@ -40,7 +40,7 @@ class ArchiveCryptoPortfolioUseCase(
private suspend fun Raise<Error>.getAccountList(userWalletId: UserWalletId): AccountList {
return catch(
block = { crudRepository.getAccounts(userWalletId = userWalletId) },
block = { crudRepository.getAccountListSync(userWalletId = userWalletId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
.getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) }

View file

@ -57,7 +57,7 @@ class GetArchivedAccountsUseCase(
private suspend fun getArchivedAccounts(userWalletId: UserWalletId): Either<Throwable, ArchivedAccountList> {
return Either.catch {
crudRepository.getArchivedAccountsSync(userWalletId = userWalletId).getOrElse {
crudRepository.getArchivedAccountListSync(userWalletId = userWalletId).getOrElse {
error("Archived accounts not found for user wallet: $userWalletId")
}
}
@ -70,7 +70,11 @@ class GetArchivedAccountsUseCase(
private suspend fun ProducerScope<Lce<Throwable, ArchivedAccountList>>.subscribeOnArchivedAccounts(
userWalletId: UserWalletId,
) {
crudRepository.getArchivedAccounts(userWalletId)
runCatching { crudRepository.getArchivedAccounts(userWalletId) }
.getOrElse {
send(it.lceError())
return
}
.distinctUntilChanged()
.retryWhen { cause, _ ->
send(cause.lceError())

View file

@ -38,6 +38,7 @@ class GetUnoccupiedAccountIndexUseCase(
block = { crudRepository.getTotalAccountsCount(userWalletId = userWalletId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
.getOrElse { raise(Error.DataNotFound) }
}
/**
@ -48,6 +49,10 @@ class GetUnoccupiedAccountIndexUseCase(
val tag: String
get() = this::class.simpleName ?: "GetUnoccupiedAccountIndexUseCase.Error"
data object DataNotFound : Error {
override fun toString(): String = "$tag: Data not found"
}
/** Error indicating that the derivation index is invalid */
data class InvalidDerivationIndex(val cause: DerivationIndex.Error) : Error {
override fun toString(): String = "$tag: Invalid derivation index: $cause"

View file

@ -44,7 +44,7 @@ class RecoverCryptoPortfolioUseCase(
private suspend fun Raise<Error>.getAccountList(userWalletId: UserWalletId): AccountList {
return catch(
block = { crudRepository.getAccounts(userWalletId = userWalletId) },
block = { crudRepository.getAccountListSync(userWalletId = userWalletId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
.getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) }
@ -52,7 +52,7 @@ class RecoverCryptoPortfolioUseCase(
private suspend fun Raise<Error>.getArchivedAccount(accountId: AccountId): ArchivedAccount {
return catch(
block = { crudRepository.getArchivedAccount(accountId = accountId) },
block = { crudRepository.getArchivedAccountSync(accountId = accountId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
.getOrElse {

View file

@ -61,7 +61,7 @@ class UpdateCryptoPortfolioUseCase(
private suspend fun Raise<Error>.getAccountList(userWalletId: UserWalletId): AccountList {
return catch(
block = { crudRepository.getAccounts(userWalletId = userWalletId) },
block = { crudRepository.getAccountListSync(userWalletId = userWalletId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
.getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) }

Some files were not shown because too many files have changed in this diff Show more