Updated on 2026-08-14
This commit is contained in:
commit
9ba66ba084
179 changed files with 5757 additions and 1156 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
|
|
@ -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.** { *; }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
17
app/src/huawei/AndroidManifest.xml
Normal file
17
app/src/huawei/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
47
app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt
Normal file
47
app/src/huawei/java/com/tangem/tap/HuaweiPushService.kt
Normal 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
|
||||
}
|
||||
}
|
||||
18
app/src/huawei/java/com/tangem/tap/di/HuaweiPushModule.kt
Normal file
18
app/src/huawei/java/com/tangem/tap/di/HuaweiPushModule.kt
Normal 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
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
@ -26,6 +26,4 @@
|
|||
<color name="text_primary_1">#1E1E1E</color>
|
||||
<color name="text_secondary">#656565</color>
|
||||
|
||||
<color name="background_splash">#000000</color>
|
||||
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ data class UserWalletItemUM(
|
|||
|
||||
data object Hidden : Balance()
|
||||
|
||||
data object NotShowing : Balance()
|
||||
|
||||
data object Locked : Balance()
|
||||
|
||||
data object Failed : Balance()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.core.ui.components.grid.entity
|
||||
|
||||
data class EnumeratedTwoColumnGridItem(
|
||||
val index: Int,
|
||||
val mnemonic: String,
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
22
core/ui/src/main/res/drawable/ic_hardware_backup_36.xml
Normal file
22
core/ui/src/main/res/drawable/ic_hardware_backup_36.xml
Normal 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>
|
||||
9
core/ui/src/main/res/drawable/ic_knight_shield_24.xml
Normal file
9
core/ui/src/main/res/drawable/ic_knight_shield_24.xml
Normal 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>
|
||||
12
core/ui/src/main/res/drawable/ic_mobile_security_24.xml
Normal file
12
core/ui/src/main/res/drawable/ic_mobile_security_24.xml
Normal 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>
|
||||
9
core/ui/src/main/res/drawable/ic_protect_24.xml
Normal file
9
core/ui/src/main/res/drawable/ic_protect_24.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
18
core/ui/src/main/res/drawable/ic_tangem_64.xml
Normal file
18
core/ui/src/main/res/drawable/ic_tangem_64.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
4
core/ui/src/main/res/values-night/colors.xml
Normal file
4
core/ui/src/main/res/values-night/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="icon_primary1">#FFFFFFFF</color>
|
||||
</resources>
|
||||
4
core/ui/src/main/res/values/colors.xml
Normal file
4
core/ui/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="icon_primary1">#FF1E1E1E</color>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.utils.buildConfig
|
||||
|
||||
interface AppConfigurationProvider {
|
||||
fun isDebug(): Boolean
|
||||
fun isHuawei(): Boolean
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()!!
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue