Updated on 2026-08-14

This commit is contained in:
Tangem 2024-04-17 12:32:52 +01:00
commit e8ccd67098
295 changed files with 4414 additions and 2971 deletions

View file

@ -39,6 +39,7 @@ dependencies {
/** Network */
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.moshi.adapters)
implementation(deps.okHttp)
implementation(deps.okHttp.prettyLogging)
implementation(deps.retrofit)
@ -53,6 +54,7 @@ dependencies {
/** Chucker */
debugImplementation(deps.chucker)
mockedImplementation(deps.chuckerStub)
externalImplementation(deps.chuckerStub)
internalImplementation(deps.chuckerStub)
releaseImplementation(deps.chuckerStub)

View file

@ -0,0 +1,14 @@
package com.tangem.datasource.api.common
/**
* Provides auth for tangemTech API
*/
interface AuthProvider {
/**
* Returns authToken for tangem tech api
*/
fun getCardPublicKey(): String
fun getCardId(): String
}

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.tangemTech
import com.tangem.datasource.config.models.ProviderModel
import retrofit2.http.GET
import retrofit2.http.Header
/**
* Tangem Tech API for app services
*
[REDACTED_AUTHOR]
*/
interface TangemTechServiceApi {
@GET("v1/networks")
suspend fun getBlockchainProviders(
@Header("card_public_key") cardPublicKey: String,
@Header("card_id") cardId: String,
): Map<String, List<ProviderModel>>
}

View file

@ -0,0 +1,87 @@
package com.tangem.datasource.asset.loader
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.squareup.moshi.adapter
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import timber.log.Timber
import javax.inject.Inject
/**
* Asset file loader
*
[REDACTED_AUTHOR]
*/
class AssetLoader @Inject constructor(
val assetReader: AssetReader,
@NetworkMoshi val moshi: Moshi,
val dispatchers: CoroutineDispatcherProvider,
) {
/** Load content [Content] of asset file [fileName] */
@OptIn(ExperimentalStdlibApi::class)
suspend inline fun <reified Content> load(fileName: String): Content? {
return runCatching(dispatchers.io) {
val json = assetReader.readJson(fileName = fileName)
moshi.adapter<Content>().fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config is null"))
parsedConfig
},
onFailure = {
Timber.e(it, "Failed to load config from assets")
null
},
)
}
/** Load list [V] values of asset file [fileName] */
suspend inline fun <reified V> loadList(fileName: String): List<V> {
return runCatching(dispatchers.io) {
val json = assetReader.readJson(fileName = fileName)
val type = Types.newParameterizedType(List::class.java, V::class.java)
val adapter = moshi.adapter<List<V>>(type)
adapter.fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config from assets")
emptyList()
},
)
}
/** Load map [String] keys and [V] values of asset file [fileName] */
suspend inline fun <reified V> loadMap(fileName: String): Map<String, V> {
return runCatching(dispatchers.io) {
val json = assetReader.readJson(fileName = fileName)
val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java)
val adapter = moshi.adapter<Map<String, V>>(type)
adapter.fromJson(json)
}
.fold(
onSuccess = { parsedConfig ->
if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config is null"))
parsedConfig.orEmpty()
},
onFailure = {
Timber.e(it, "Failed to load config from assets")
emptyMap()
},
)
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.asset
package com.tangem.datasource.asset.reader
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
@ -21,8 +21,7 @@ internal class AndroidAssetReader @Inject constructor(
.use(BufferedReader::readText)
}
override fun openFile(fileName: String): InputStream {
return context.assets
.open(fileName)
override fun openFile(file: String): InputStream {
return context.assets.open(file)
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.datasource.asset
package com.tangem.datasource.asset.reader
import java.io.InputStream

View file

@ -146,8 +146,8 @@ internal class ConfigManagerImpl @Inject constructor() : ConfigManager {
blockBookRest = accessTokens.bitcoin?.blockBookRest,
),
algorand = GetBlockAccessToken(rest = accessTokens.algorand?.rest),
zkSync = GetBlockAccessToken(rest = accessTokens.zksync?.jsonRPC),
polygonZkevm = GetBlockAccessToken(rest = accessTokens.polygonZkevm?.jsonRPC),
zkSyncEra = GetBlockAccessToken(rest = accessTokens.zksync?.jsonRPC),
polygonZkEvm = GetBlockAccessToken(rest = accessTokens.polygonZkevm?.jsonRPC),
base = GetBlockAccessToken(rest = accessTokens.base?.jsonRPC),
)
}

View file

@ -2,7 +2,7 @@ package com.tangem.datasource.config
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.tangem.datasource.asset.AssetReader
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.datasource.config.models.ConfigModel
import com.tangem.datasource.config.models.ConfigValueModel
import com.tangem.datasource.config.models.FeatureModel
@ -11,6 +11,7 @@ import timber.log.Timber
/**
[REDACTED_AUTHOR]
*/
@Deprecated(message = "Use AssetReader instead")
class FeaturesLocalLoader(
private val assetReader: AssetReader,
private val moshi: Moshi,

View file

@ -7,6 +7,7 @@ import com.squareup.moshi.JsonClass
[REDACTED_AUTHOR]
*/
// TODO remove
class FeatureModel(
val isTopUpEnabled: Boolean,
val isCreatingTwinCardsAllowed: Boolean,

View file

@ -0,0 +1,32 @@
package com.tangem.datasource.config.models
import com.squareup.moshi.Json
/** Config provider model */
sealed class ProviderModel {
/**
* Example,
* {
* "type": "public",
* "url": "https://example.com"
* }
*/
data class Public(
@Json(name = "url") val url: String,
) : ProviderModel()
/**
* Example,
* {
* "type": "private",
* "name": "nownodes"
* }
*/
data class Private(
@Json(name = "name") val name: String,
) : ProviderModel()
/** Unsupported type */
data object UnsupportedType : ProviderModel()
}

View file

@ -1,22 +0,0 @@
package com.tangem.datasource.di
import com.tangem.blockchain.common.AccountCreator
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.blockchain.DefaultAccountCreator
import com.tangem.lib.auth.AuthProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object AccountCreatorModule {
@Provides
@Singleton
fun provideAccountCreator(authProvider: AuthProvider, tangemTechApi: TangemTechApi): AccountCreator {
return DefaultAccountCreator(authProvider, tangemTechApi)
}
}

View file

@ -1,7 +1,7 @@
package com.tangem.datasource.di
import com.tangem.datasource.asset.AndroidAssetReader
import com.tangem.datasource.asset.AssetReader
import com.tangem.datasource.asset.reader.AndroidAssetReader
import com.tangem.datasource.asset.reader.AssetReader
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn

View file

@ -1,21 +0,0 @@
package com.tangem.datasource.di
import com.tangem.blockchain.common.datastorage.BlockchainDataStorage
import com.tangem.datasource.local.blockchain.DefaultBlockchainDataStorage
import com.tangem.datasource.local.preferences.AppPreferencesStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object BlockchainDataStorageModule {
@Provides
@Singleton
fun provideBlockchainDataStorage(appPreferencesStore: AppPreferencesStore): BlockchainDataStorage {
return DefaultBlockchainDataStorage(appPreferencesStore)
}
}

View file

@ -1,11 +1,13 @@
package com.tangem.datasource.di
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.tangem.common.json.MoshiJsonConverter
import com.tangem.datasource.api.common.BigDecimalAdapter
import com.tangem.datasource.api.common.DateTimeAdapter
import com.tangem.datasource.api.common.LocalDateAdapter
import com.tangem.datasource.config.models.ProviderModel
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -21,6 +23,12 @@ class MoshiModule {
@NetworkMoshi
fun provideNetworkMoshi(): Moshi {
return Moshi.Builder()
.add(
PolymorphicJsonAdapterFactory.of(ProviderModel::class.java, "type")
.withSubtype(ProviderModel.Public::class.java, "public")
.withSubtype(ProviderModel.Private::class.java, "private")
.withDefaultValue(ProviderModel.UnsupportedType),
)
.add(BigDecimalAdapter())
.add(LocalDateAdapter())
.add(DateTimeAdapter())

View file

@ -6,6 +6,7 @@ import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.TangemTechServiceApi
import com.tangem.datasource.utils.RequestHeader.*
import com.tangem.datasource.utils.addHeaders
import com.tangem.datasource.utils.addLoggers
@ -19,6 +20,7 @@ import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@ -96,6 +98,33 @@ class NetworkModule {
.create(TangemTechApi::class.java)
}
@Provides
@Singleton
fun provideTangemTechServiceApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
appVersionProvider: AppVersionProvider,
): TangemTechServiceApi {
return Retrofit.Builder()
.addConverterFactory(MoshiConverterFactory.create(moshi))
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
.baseUrl(PROD_TANGEM_TECH_BASE_URL)
.client(
OkHttpClient.Builder()
.callTimeout(timeout = 5, unit = TimeUnit.SECONDS)
.addHeaders(
CacheControlHeader,
AppVersionPlatformHeaders(appVersionProvider),
// TODO("refactor header init") get auth data after biometric auth to avoid race condition
// AuthenticationHeader(authProvider),
)
.addLoggers(context)
.build(),
)
.build()
.create(TangemTechServiceApi::class.java)
}
private companion object {
const val PROD_EXPRESS_BASE_URL = "https://express.tangem.com/v1/"
const val DEV_EXPRESS_BASE_URL = "[REDACTED_ENV_URL]"

View file

@ -2,7 +2,7 @@ package com.tangem.datasource.di
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.tangem.datasource.asset.AssetReader
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.datasource.local.testnet.DefaultTestnetTokensStorage
import com.tangem.datasource.local.testnet.TestnetTokensStorage
import dagger.Module

View file

@ -1,31 +0,0 @@
package com.tangem.datasource.local.blockchain
import com.tangem.blockchain.common.AccountCreator
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.extensions.Result
import com.tangem.common.extensions.toHexString
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CreateUserNetworkAccountBody
import com.tangem.lib.auth.AuthProvider
internal class DefaultAccountCreator(
private val authProvider: AuthProvider,
private val tangemTechApi: TangemTechApi,
) : AccountCreator {
override suspend fun createAccount(blockchain: Blockchain, walletPublicKey: ByteArray): Result<String> {
val request = CreateUserNetworkAccountBody(blockchain.id.removeSuffix("/test"), walletPublicKey.toHexString())
return try {
val response = tangemTechApi.createUserNetworkAccount(
cardPublicKey = authProvider.getCardPublicKey(),
cardId = authProvider.getCardId(),
body = request,
).getOrThrow()
Result.Success(response.data.accountId)
} catch (e: Exception) {
Result.Failure(BlockchainSdkError.FailedToCreateAccount)
}
}
}

View file

@ -1,29 +0,0 @@
package com.tangem.datasource.local.blockchain
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.blockchain.common.datastorage.BlockchainDataStorage
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
/**
* [BlockchainDataStorage] implementation
*
* @property appPreferencesStore app preferences store
*
[REDACTED_AUTHOR]
*/
internal class DefaultBlockchainDataStorage(
private val appPreferencesStore: AppPreferencesStore,
) : BlockchainDataStorage {
override suspend fun getOrNull(key: String): String? {
return appPreferencesStore.getSyncOrNull(key = stringPreferencesKey(name = key))
}
override suspend fun store(key: String, value: String) {
appPreferencesStore.edit {
it[stringPreferencesKey(key)] = value
}
}
}

View file

@ -5,9 +5,13 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.APP_LAUNCH_COUNT_
import com.tangem.datasource.local.preferences.PreferencesKeys.FUNDS_FOUND_DATE_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.IS_TANGEM_TOS_ACCEPTED_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SAVE_USER_WALLETS_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_OPEN_WELCOME_ON_RESUME_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.USED_CARDS_INFO_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.USER_WAS_INTERACT_WITH_RATING_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.WAS_APPLICATION_STOPPED_KEY
import com.tangem.datasource.local.preferences.PreferencesKeys.WAS_TWINS_ONBOARDING_SHOWN
/**
@ -19,6 +23,8 @@ object PreferencesKeys {
val SAVE_USER_WALLETS_KEY by lazy { booleanPreferencesKey(name = "saveUserWallets") }
val SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY by lazy { booleanPreferencesKey("saveUserWalletShown") }
val APP_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "launchCount") }
val SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY by lazy { intPreferencesKey(name = "showRatingDialogAtLaunchCount") }
@ -75,6 +81,12 @@ object PreferencesKeys {
val SEND_TAP_HELP_PREVIEW_KEY by lazy { booleanPreferencesKey(name = "sendTapHelpPreview") }
val WAS_APPLICATION_STOPPED_KEY by lazy { booleanPreferencesKey(name = "applicationStopped") }
val SHOULD_OPEN_WELCOME_ON_RESUME_KEY by lazy { booleanPreferencesKey(name = "openWelcomeOnResume") }
val SHOULD_SAVE_ACCESS_CODES_KEY by lazy { booleanPreferencesKey(name = "saveAccessCodes") }
fun getStart2CoinTOSAcceptedKey(region: String?) = booleanPreferencesKey(name = "start2Coin_tos_accepted_$region")
}
@ -82,6 +94,7 @@ object PreferencesKeys {
internal fun getTapPrefKeysToMigrate(): Set<String> {
return setOf(
SAVE_USER_WALLETS_KEY,
SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY,
APP_LAUNCH_COUNT_KEY,
SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY,
FUNDS_FOUND_DATE_KEY,
@ -89,6 +102,9 @@ internal fun getTapPrefKeysToMigrate(): Set<String> {
USED_CARDS_INFO_KEY,
WAS_TWINS_ONBOARDING_SHOWN,
IS_TANGEM_TOS_ACCEPTED_KEY,
WAS_APPLICATION_STOPPED_KEY,
SHOULD_OPEN_WELCOME_ON_RESUME_KEY,
SHOULD_SAVE_ACCESS_CODES_KEY,
)
.map(Preferences.Key<*>::name)
.toSet()

View file

@ -1,7 +1,7 @@
package com.tangem.datasource.local.testnet
import com.squareup.moshi.JsonAdapter
import com.tangem.datasource.asset.AssetReader
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.datasource.local.testnet.models.TestnetTokensConfig
/**
@ -17,6 +17,7 @@ internal class DefaultTestnetTokensStorage(
private val adapter: JsonAdapter<TestnetTokensConfig>,
) : TestnetTokensStorage {
@Deprecated(message = "Use AssetReader instead")
override fun getConfig(): TestnetTokensConfig {
return requireNotNull(
value = adapter.fromJson(

View file

@ -1,8 +1,7 @@
package com.tangem.datasource.utils
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.lib.auth.AppVersionProvider
import com.tangem.lib.auth.AuthBearerProvider
import com.tangem.lib.auth.AuthProvider
import com.tangem.lib.auth.ExpressAuthProvider
/**
@ -15,7 +14,7 @@ sealed class RequestHeader(vararg pairs: Pair<String, () -> String>) {
/** Header list */
val values: List<Pair<String, () -> String>> = pairs.toList()
object CacheControlHeader : RequestHeader("Cache-Control" to { "max-age=600" })
data object CacheControlHeader : RequestHeader("Cache-Control" to { "max-age=600" })
class AuthenticationHeader(authProvider: AuthProvider) : RequestHeader(
"card_id" to { authProvider.getCardId() },
@ -28,10 +27,6 @@ sealed class RequestHeader(vararg pairs: Pair<String, () -> String>) {
"session-id" to { expressAuthProvider.getSessionId() },
)
class AuthBearerHeader(authBearerProvider: AuthBearerProvider) : RequestHeader(
"Authorization" to { "Bearer " + authBearerProvider.getApiKey() },
)
class AppVersionPlatformHeaders(appVersionProvider: AppVersionProvider) : RequestHeader(
"version" to { appVersionProvider.getAppVersion() },
"platform" to { "android" },

1
core/decompose/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,15 @@
plugins {
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
dependencies {
implementation(projects.core.utils)
api(deps.decompose)
implementation(deps.kotlin.coroutines)
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,23 @@
package com.tangem.core.decompose.context
import com.arkivanov.decompose.ComponentContext
import com.tangem.core.decompose.di.HiltComponentBuilderOwner
import com.tangem.core.decompose.navigation.NavigationOwner
import com.tangem.core.decompose.ui.UiMessageSenderOwner
import com.tangem.core.decompose.utils.ComponentScopeOwner
import com.tangem.core.decompose.utils.DispatchersOwner
import com.tangem.core.decompose.utils.TagsOwner
/**
* Interface for the application component context.
*
* It combines several other interfaces related to navigation, dispatching, UI messaging, etc.
*/
interface AppComponentContext :
ComponentContext,
NavigationOwner,
ComponentScopeOwner,
DispatchersOwner,
UiMessageSenderOwner,
HiltComponentBuilderOwner,
TagsOwner

View file

@ -0,0 +1,73 @@
package com.tangem.core.decompose.context
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.childContext
import com.arkivanov.essenty.lifecycle.Lifecycle
import com.tangem.core.decompose.di.HiltComponentBuilderOwner
import com.tangem.core.decompose.navigation.NavigationOwner
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.DefaultUiMessageSender
import com.tangem.core.decompose.ui.UiMessageHandler
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.decompose.ui.UiMessageSenderOwner
import com.tangem.core.decompose.utils.ComponentCoroutineScope
import com.tangem.core.decompose.utils.DispatchersOwner
import kotlinx.coroutines.CoroutineScope
/**
* Creates a new child [AppComponentContext] with the provided [key] and optional [lifecycle].
*
* @param key The key to use.
* @param lifecycle The [Lifecycle] to use. If not provided, the parent's lifecycle will be used.
* @param router The [Router] to use in the child. If not provided, the parent's router will be used.
* @param messageHandler The [UiMessageHandler] to use in the child. If not provided, the parent's message sender will
* be used.
*
* @see childByContext
* */
fun AppComponentContext.child(
key: String,
lifecycle: Lifecycle? = null,
router: Router? = null,
messageHandler: UiMessageHandler? = null,
): AppComponentContext = childByContext(
componentContext = childContext(key, lifecycle),
router = router,
messageHandler = messageHandler,
)
/**
* Creates a new child [AppComponentContext] with the provided [componentContext].
*
* @param componentContext The [ComponentContext] to use.
* @param router The [Router] to use in the child. If not provided, the parent's router will be used.
* @param messageHandler The [UiMessageHandler] to use in the child. If not provided, the parent's message sender will
* be used.
*
* @see child
* */
fun AppComponentContext.childByContext(
componentContext: ComponentContext,
router: Router? = null,
messageHandler: UiMessageHandler? = null,
): AppComponentContext = object :
AppComponentContext,
ComponentContext by componentContext,
NavigationOwner by this@childByContext,
UiMessageSenderOwner by this@childByContext,
DispatchersOwner by this@childByContext,
HiltComponentBuilderOwner by this@childByContext {
override val tags: HashMap<String, Any> = HashMap()
override val componentScope: CoroutineScope = ComponentCoroutineScope(lifecycle, dispatchers)
override val messageSender: UiMessageSender = messageHandler
?.let(::DefaultUiMessageSender)
?: this@childByContext.messageSender
override val router: Router
get() = router ?: this@childByContext.router
}

View file

@ -0,0 +1,35 @@
package com.tangem.core.decompose.context
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.essenty.instancekeeper.getOrCreate
import com.tangem.core.decompose.di.DecomposeComponent
import com.tangem.core.decompose.navigation.AppNavigationProvider
import com.tangem.core.decompose.navigation.DefaultAppNavigationProvider
import com.tangem.core.decompose.navigation.DefaultRouter
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.DefaultUiMessageSender
import com.tangem.core.decompose.ui.UiMessageHandler
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.decompose.utils.ComponentCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
class DefaultAppComponentContext(
componentContext: ComponentContext,
messageHandler: UiMessageHandler,
override val dispatchers: CoroutineDispatcherProvider,
override val hiltComponentBuilder: DecomposeComponent.Builder,
) : AppComponentContext, ComponentContext by componentContext {
override val tags: HashMap<String, Any> = HashMap()
override val componentScope: CoroutineScope = ComponentCoroutineScope(lifecycle, dispatchers)
override val messageSender: UiMessageSender = DefaultUiMessageSender(messageHandler)
override val navigationProvider: AppNavigationProvider
get() = instanceKeeper.getOrCreate { DefaultAppNavigationProvider() }
override val router: Router
get() = instanceKeeper.getOrCreate { DefaultRouter(navigationProvider) }
}

View file

@ -0,0 +1,9 @@
package com.tangem.core.decompose.di
/**
* Annotation for marking a dependency as a component scoped.
*
* This means that the lifecycle of the dependency is limited to the lifecycle of the component it is attached to.
*/
@Retention(AnnotationRetention.SOURCE)
annotation class ComponentScoped

View file

@ -0,0 +1,47 @@
package com.tangem.core.decompose.di
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import dagger.BindsInstance
import dagger.hilt.DefineComponent
import dagger.hilt.components.SingletonComponent
/**
* Interface for the Decompose component.
*
* It is annotated as [ComponentScoped], meaning it has a lifecycle that is scoped to the component.
*/
@ComponentScoped
@DefineComponent(parent = SingletonComponent::class)
interface DecomposeComponent {
/**
* Builder interface for the component.
*/
@DefineComponent.Builder
interface Builder {
/**
* Sets the router for the component.
*
* @param router The router to set.
* @return The builder instance.
*/
fun router(@BindsInstance router: Router): Builder
/**
* Sets the UI message sender for the component.
*
* @param uiMessageSender The UI message sender to set.
* @return The builder instance.
*/
fun uiMessageSender(@BindsInstance uiMessageSender: UiMessageSender): Builder
/**
* Builds the Decompose component.
*
* @return The built Decompose component.
*/
fun build(): DecomposeComponent
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.core.decompose.di
/**
* Interface for owning a Hilt component builder.
*/
interface HiltComponentBuilderOwner {
/**
* Provides access to the Hilt component builder instance.
*/
val hiltComponentBuilder: DecomposeComponent.Builder
}

View file

@ -0,0 +1,36 @@
package com.tangem.core.decompose.model
import com.arkivanov.essenty.instancekeeper.InstanceKeeper
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
/**
* Abstract class for a component's model.
*
* It provides access to the coroutine dispatchers and a coroutine scope which will survive re-creation of component
* and will be destroyed when the component is destroyed.
*
* Also, it can inject and use some component features like [Router] and [UiMessageSender].
*/
abstract class Model : InstanceKeeper.Instance {
/**
* Provides access to the coroutine dispatchers.
*/
protected abstract val dispatchers: CoroutineDispatcherProvider
/**
* The coroutine scope for the model. That will be cancelled when the model is destroyed.
*/
protected val modelScope by lazy {
CoroutineScope(context = dispatchers.mainImmediate + SupervisorJob())
}
override fun onDestroy() {
runCatching { modelScope.cancel() }
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.core.decompose.model
import com.arkivanov.essenty.instancekeeper.getOrCreate
import com.arkivanov.essenty.instancekeeper.getOrCreateSimple
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.di.DecomposeComponent
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import javax.inject.Provider
/**
* Entry point for the models in the application.
*
* It provides a map of model providers.
*/
@EntryPoint
@InstallIn(DecomposeComponent::class)
interface ModelsEntryPoint {
fun models(): Map<Class<*>, Provider<Model>>
}
/**
* Gets or creates a component's [Model].
*/
inline fun <reified M : Model> AppComponentContext.getOrCreateModel(): M {
val modelKey = "model_${M::class.simpleName}"
val entryPoint = instanceKeeper.getOrCreateSimple(key = "modelsEntryPoint") {
val hiltComponent = hiltComponentBuilder
.router(router)
.uiMessageSender(messageSender)
.build()
EntryPoints.get(hiltComponent, ModelsEntryPoint::class.java)
}
val model = instanceKeeper.getOrCreate(modelKey) {
requireNotNull(entryPoint.models()[M::class.java]?.get()) {
"Model ${M::class.simpleName} is not provided"
}
}
val isModelExist = tags.getOrElse(modelKey) { false } as Boolean
if (!isModelExist) {
tags[modelKey] = true
}
return model as M
}

View file

@ -0,0 +1,28 @@
@file:Suppress("UNCHECKED_CAST")
package com.tangem.core.decompose.navigation
import com.arkivanov.decompose.router.stack.StackNavigation
/**
* Interface for providing application navigation.
* It provides or creates a StackNavigation instance for the application.
*/
interface AppNavigationProvider {
/**
* Gets or creates a StackNavigation instance.
*
* @return The StackNavigation instance.
*/
fun getOrCreate(): StackNavigation<Route>
}
/**
* Gets or creates a [StackNavigation] instance of a specific type.
*
* @return The [StackNavigation] instance.
*/
fun <R : Route> AppNavigationProvider.getOrCreateTyped(): StackNavigation<R> {
return getOrCreate() as StackNavigation<R>
}

View file

@ -0,0 +1,13 @@
package com.tangem.core.decompose.navigation
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.essenty.instancekeeper.InstanceKeeper
internal class DefaultAppNavigationProvider : AppNavigationProvider, InstanceKeeper.Instance {
private var navigation: StackNavigation<Route>? = null
override fun getOrCreate(): StackNavigation<Route> {
return navigation ?: StackNavigation<Route>().also { navigation = it }
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.core.decompose.navigation
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.popWhile
import com.arkivanov.decompose.router.stack.pushNew
import com.arkivanov.essenty.instancekeeper.InstanceKeeper
internal class DefaultRouter(
private val navigationProvider: AppNavigationProvider,
) : Router, InstanceKeeper.Instance {
private val navigation: StackNavigation<Route>
get() = navigationProvider.getOrCreate()
@OptIn(ExperimentalDecomposeApi::class)
override fun push(route: Route, onComplete: (isSuccess: Boolean) -> Unit) {
navigation.pushNew(route, onComplete)
}
override fun pop(onComplete: (isSuccess: Boolean) -> Unit) {
navigation.pop(onComplete)
}
override fun popTo(route: Route, onComplete: (isSuccess: Boolean) -> Unit) {
navigation.popWhile(
predicate = { it != route },
onComplete = onComplete,
)
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.core.decompose.navigation
/**
* Interface for owning navigation-related properties.
*/
interface NavigationOwner {
/**
* The [Router] instance.
*/
val router: Router
/**
* The [AppNavigationProvider] instance.
*/
val navigationProvider: AppNavigationProvider
}

View file

@ -0,0 +1,6 @@
package com.tangem.core.decompose.navigation
/**
* Interface for a route in the application.
*/
interface Route

View file

@ -0,0 +1,31 @@
package com.tangem.core.decompose.navigation
/**
* Interface for a router in the application.
* It provides methods for navigating through the application.
*/
interface Router {
/**
* Pushes a new route to the navigation stack.
*
* @param route The route to push.
* @param onComplete The callback to be invoked when the operation is complete.
*/
fun push(route: Route, onComplete: (isSuccess: Boolean) -> Unit = {})
/**
* Pops the top route from the navigation stack.
*
* @param onComplete The callback to be invoked when the operation is complete.
*/
fun pop(onComplete: (isSuccess: Boolean) -> Unit = {})
/**
* Pops routes from the navigation stack until the specified route is found.
*
* @param route The route to pop to.
* @param onComplete The callback to be invoked when the operation is complete.
*/
fun popTo(route: Route, onComplete: (isSuccess: Boolean) -> Unit = {})
}

View file

@ -0,0 +1,10 @@
package com.tangem.core.decompose.ui
internal class DefaultUiMessageSender(
private val handler: UiMessageHandler,
) : UiMessageSender {
override fun send(message: UiMessage) {
handler.handleMessage(message)
}
}

View file

@ -0,0 +1,9 @@
package com.tangem.core.decompose.ui
/**
* Interface for a message that can be sent to a [UiMessageSender] and handled by a [UiMessageHandler].
*
* @see UiMessageSender
* @see UiMessageHandler
*/
interface UiMessage

View file

@ -0,0 +1,17 @@
package com.tangem.core.decompose.ui
/**
* Interface for handling UI messages.
*
* @see UiMessage
* @see UiMessageSender
*/
interface UiMessageHandler {
/**
* Handles the given UI message.
*
* @param message The UI message to handle.
*/
fun handleMessage(message: UiMessage)
}

View file

@ -0,0 +1,17 @@
package com.tangem.core.decompose.ui
/**
* Interface for sending messages to UI.
*
* @see UiMessage
* @see UiMessageHandler
* */
interface UiMessageSender {
/**
* Sends the given UI message.
*
* @param message The UI message to send.
* */
fun send(message: UiMessage)
}

View file

@ -0,0 +1,12 @@
package com.tangem.core.decompose.ui
/**
* Interface for owning a [UiMessageSender].
* */
interface UiMessageSenderOwner {
/**
* The [UiMessageSender] instance.
* */
val messageSender: UiMessageSender
}

View file

@ -0,0 +1,20 @@
package com.tangem.core.decompose.utils
import com.arkivanov.essenty.lifecycle.Lifecycle
import com.arkivanov.essenty.lifecycle.doOnDestroy
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
/**
* [CoroutineDispatcherProvider.mainImmediate] dispatcher.
* */
@Suppress("FunctionName")
internal fun ComponentCoroutineScope(lifecycle: Lifecycle, dispatchers: CoroutineDispatcherProvider): CoroutineScope {
val scope = CoroutineScope(context = dispatchers.mainImmediate + SupervisorJob())
lifecycle.doOnDestroy(scope::cancel)
return scope
}

View file

@ -0,0 +1,16 @@
package com.tangem.core.decompose.utils
import kotlinx.coroutines.CoroutineScope
/**
* Interface for owning a component scope.
*/
interface ComponentScopeOwner {
/**
* Provides access to the component's [CoroutineScope] instance.
*
* This scope is used for launching coroutines that are bound to the component's lifecycle.
*/
val componentScope: CoroutineScope
}

View file

@ -0,0 +1,14 @@
package com.tangem.core.decompose.utils
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
/**
* Interface for owning a [CoroutineDispatcherProvider].
*/
interface DispatchersOwner {
/**
* Provides access to the [CoroutineDispatcherProvider] instance.
*/
val dispatchers: CoroutineDispatcherProvider
}

View file

@ -0,0 +1,12 @@
package com.tangem.core.decompose.utils
/**
* Interface for owning tags.
*/
interface TagsOwner {
/**
* Provides access to the tags map instance.
*/
val tags: HashMap<String, Any>
}

View file

@ -11,10 +11,6 @@
"name": "REDESIGNED_SEND_SCREEN_ENABLED",
"version": "5.9.0"
},
{
"name": "GENERAL_USER_WALLETS_LIST_MANAGER_ENABLED",
"version": "5.8.0"
},
{
"name": "LOCAL_USER_LOGS_ENABLED",
"version": "5.8.0"
@ -26,5 +22,9 @@
{
"name": "WC_SOLANA_TX_SIGN_ENABLED",
"version": "5.11.0"
},
{
"name": "TOKEN_LIST_LCE_ENABLED",
"version": "5.10.0"
}
]

View file

@ -10,7 +10,7 @@ import com.tangem.core.featuretoggle.manager.ProdFeatureTogglesManager
import com.tangem.core.featuretoggle.storage.LocalFeatureTogglesStorage
import com.tangem.core.featuretoggle.version.DefaultVersionProvider
import com.tangem.core.featuretoggles.BuildConfig
import com.tangem.datasource.asset.AssetReader
import com.tangem.datasource.asset.reader.AssetReader
import com.tangem.datasource.local.preferences.AppPreferencesStore
import dagger.Module
import dagger.Provides

View file

@ -3,7 +3,7 @@ package com.tangem.core.featuretoggle.storage
import androidx.annotation.VisibleForTesting
import com.squareup.moshi.JsonAdapter
import com.tangem.core.featuretoggle.storage.LocalFeatureTogglesStorage.Companion.LOCAL_CONFIG_PATH
import com.tangem.datasource.asset.AssetReader
import com.tangem.datasource.asset.reader.AssetReader
import timber.log.Timber
import kotlin.properties.Delegates
@ -24,6 +24,7 @@ internal class LocalFeatureTogglesStorage(
override var featureToggles: List<FeatureToggle> by Delegates.notNull()
private set
@Deprecated(message = "Use AssetReader instead")
override suspend fun init() {
runCatching { requireNotNull(jsonAdapter.fromJson(assetReader.readJson(LOCAL_CONFIG_PATH))) }
.onSuccess { featureToggles = it }

View file

@ -3,12 +3,11 @@ package com.tangem.core.featuretoggle.storage
import android.annotation.SuppressLint
import com.google.common.truth.Truth
import com.squareup.moshi.JsonAdapter
import com.tangem.datasource.asset.AssetReader
import com.tangem.datasource.asset.reader.AssetReader
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.verifyAll
import io.mockk.verifyOrder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.IOException
@ -16,7 +15,6 @@ import java.io.IOException
/**
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalCoroutinesApi::class)
@SuppressLint("CheckResult")
internal class LocalFeatureTogglesStorageTest {

View file

@ -540,6 +540,7 @@
<string name="token_button_unavailability_generic_description">Выбранная операция в данный момент недоступна. Попробуйте позже.</string>
<string name="token_button_unavailability_reason_buy_unavailable">В данный момент покупка монеты %s недоступна. Но мы работаем над её добавлением.</string>
<string name="token_button_unavailability_reason_empty_balance">У вас нет средств для отправки. Пополните счет, чтобы иметь возможность отправить с него средства.</string>
<string name="token_button_unavailability_reason_no_quotes">Выбранная операция в данный момент недоступна. Попробуйте позже.</string>
<string name="token_button_unavailability_reason_not_exchangeable">Обмен %s не доступен. Но мы работаем над его добавлением.</string>
<string name="token_button_unavailability_reason_sell_unavailable">В данный момент продажа монеты %s недоступна. Но мы работаем над её добавлением.</string>
<string name="token_details_generate_xpub">Сгенерировать XPUB</string>

View file

@ -537,6 +537,7 @@
<string name="token_button_unavailability_generic_description">This operation is currently unavailable. Please try again later.</string>
<string name="token_button_unavailability_reason_buy_unavailable">The purchase of the %s is currently unavailable. But we are working on adding it.</string>
<string name="token_button_unavailability_reason_empty_balance">You do not have funds to send. Top up your account to be able to send funds from it.</string>
<string name="token_button_unavailability_reason_no_quotes">This operation is currently unavailable. Please try again later.</string>
<string name="token_button_unavailability_reason_not_exchangeable">%s swap is not available. But we are working on adding it.</string>
<string name="token_button_unavailability_reason_sell_unavailable">Sell of the %s coin is currently unavailable. But we are working on adding it.</string>
<string name="token_details_generate_xpub">Generate XPUB</string>

View file

@ -19,6 +19,7 @@ dependencies {
/** Project - Core */
implementation(projects.core.res)
implementation(projects.core.utils)
implementation(projects.core.decompose)
/** AndroidX libraries */
implementation(deps.androidx.fragment.ktx)

View file

@ -0,0 +1,47 @@
package com.tangem.core.ui.message
import androidx.compose.material.SnackbarDuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.ui.UiMessage
import com.tangem.core.ui.extensions.TextReference
/**
* Event that is used to show a message in the UI.
*
* @see EventMessageHandler
* */
@Immutable
sealed interface EventMessage : UiMessage
/**
* Shows a snackbar.
*
* @param message The message to show.
* @param duration The duration of the snackbar.
* @param actionLabel The label of the action button.
* @param action The action to perform when the action button is clicked.
* */
data class SnackbarMessage(
val message: TextReference,
val duration: SnackbarDuration = SnackbarDuration.Short,
val actionLabel: TextReference? = null,
val action: (() -> Unit)? = null,
) : EventMessage
/**
* Shows a [content] in the UI.
*
* @param content The content to show.
* */
data class ContentMessage(val content: Content) : EventMessage {
@Stable
fun interface Content {
@Suppress("ComposableFunctionName", "TopLevelComposableFunctions")
@Composable
operator fun invoke(onDismiss: () -> Unit)
}
}

View file

@ -0,0 +1,43 @@
package com.tangem.core.ui.message
import android.content.Context
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.SnackbarResult
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.extensions.resolveReference
@Composable
fun EventMessageEffect(messageHandler: EventMessageHandler, snackbarHostState: SnackbarHostState) {
val messageEvent by messageHandler.collectAsState()
val context = LocalContext.current
var contentMessage: ContentMessage? by remember { mutableStateOf(value = null) }
EventEffect(event = messageEvent) { message ->
when (message) {
is ContentMessage -> {
contentMessage = message
}
is SnackbarMessage -> {
showSnackbar(snackbarHostState, message, context)
}
}
}
contentMessage?.content?.invoke(
onDismiss = { contentMessage = null },
)
}
private suspend fun showSnackbar(snackbarHostState: SnackbarHostState, message: SnackbarMessage, context: Context) {
val result = snackbarHostState.showSnackbar(
message = message.message.resolveReference(context.resources),
actionLabel = message.actionLabel?.resolveReference(context.resources),
duration = message.duration,
)
if (result == SnackbarResult.ActionPerformed) {
message.action?.invoke()
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.core.ui.message
import com.tangem.core.decompose.ui.UiMessage
import com.tangem.core.decompose.ui.UiMessageHandler
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Message handler that is used to show or remove an [EventMessage] in the UI.
*/
class EventMessageHandler(
private val events: MutableStateFlow<StateEvent<EventMessage>>,
) : UiMessageHandler, StateFlow<StateEvent<EventMessage>> by events {
override fun handleMessage(message: UiMessage) {
if (message !is EventMessage) return
events.value = triggeredEvent(message, ::consumeEvent)
}
private fun consumeEvent() {
events.value = consumedEvent()
}
}

View file

@ -0,0 +1,9 @@
package com.tangem.core.ui.test
object TestTags {
const val STORIES_SCREEN = "STORIES_SCREEN_CONTAINER"
const val STORIES_SCREEN_SCAN_BUTTON = "STORIES_SCREEN_SCAN_BUTTON"
const val STORIES_SCREEN_ORDER_BUTTON = "STORIES_SCREEN_ORDER_BUTTON"
const val WALLET_SCREEN = "WALLET_SCREEN_CONTAINER"
}

View file

@ -8,6 +8,7 @@ import javax.inject.Inject
interface CoroutineDispatcherProvider {
val main: CoroutineDispatcher
val mainImmediate: CoroutineDispatcher
val io: CoroutineDispatcher
val default: CoroutineDispatcher
val single: CoroutineDispatcher
@ -15,6 +16,7 @@ interface CoroutineDispatcherProvider {
class AppCoroutineDispatcherProvider @Inject constructor() : CoroutineDispatcherProvider {
override val main: CoroutineDispatcher = Dispatchers.Main
override val mainImmediate: CoroutineDispatcher = Dispatchers.Main.immediate
override val io: CoroutineDispatcher = Dispatchers.IO
override val default: CoroutineDispatcher = Dispatchers.Default
override val single: CoroutineDispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher()
@ -22,6 +24,7 @@ class AppCoroutineDispatcherProvider @Inject constructor() : CoroutineDispatcher
class TestingCoroutineDispatcherProvider(
override val main: CoroutineDispatcher = Dispatchers.Unconfined,
override val mainImmediate: CoroutineDispatcher = Dispatchers.Unconfined,
override val io: CoroutineDispatcher = Dispatchers.Unconfined,
override val default: CoroutineDispatcher = Dispatchers.Unconfined,
override val single: CoroutineDispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher(),