Updated on 2026-08-14
This commit is contained in:
parent
608121ab50
commit
82fd854852
49 changed files with 980 additions and 117 deletions
1
domain/.gitignore
vendored
Normal file
1
domain/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
75
domain/build.gradle
Normal file
75
domain/build.gradle
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 31
|
||||
|
||||
defaultConfig {
|
||||
minSdk 21
|
||||
targetSdk 31
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles "consumer-rules.pro"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
debuggable true
|
||||
minifyEnabled false
|
||||
}
|
||||
release {
|
||||
debuggable false
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
// composeOptions {
|
||||
// kotlinCompilerExtensionVersion '1.1.0'
|
||||
// }
|
||||
packagingOptions {
|
||||
exclude 'lib/x86_64/darwin/libscrypt.dylib'
|
||||
exclude 'lib/x86_64/freebsd/libscrypt.so'
|
||||
exclude 'lib/x86_64/linux/libscrypt.so'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation implementation(project(path: ':network'))
|
||||
|
||||
// Tangem sdk's
|
||||
implementation 'com.tangem:blockchain:develop-66'
|
||||
implementation 'com.tangem.tangem-sdk-kotlin:core:develop-140'
|
||||
implementation 'com.tangem.tangem-sdk-kotlin:android:develop-140'
|
||||
|
||||
// Kotlin
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
|
||||
|
||||
// State management
|
||||
implementation "org.rekotlin:rekotlin:1.0.4"
|
||||
|
||||
// Network
|
||||
//TODO: it must depends from network module
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.8.1'
|
||||
implementation 'com.squareup.retrofit2:converter-moshi:2.6.0'
|
||||
implementation 'com.squareup.moshi:moshi:1.12.0'
|
||||
implementation "com.squareup.moshi:moshi-kotlin:1.12.0"
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:4.2.2'
|
||||
|
||||
// Logs
|
||||
implementation 'com.jakewharton.timber:timber:4.7.1'
|
||||
|
||||
// Tests
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation "com.google.truth:truth:1.1.3"
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
}
|
||||
21
domain/proguard-rules.pro
vendored
Normal file
21
domain/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.domain.features
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.tangem.feature2.test", appContext.packageName)
|
||||
}
|
||||
}
|
||||
4
domain/src/main/AndroidManifest.xml
Normal file
4
domain/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="com.tangem.domain.features">
|
||||
|
||||
</manifest>
|
||||
24
domain/src/main/java/com/tangem/domain/common/DomainError.kt
Normal file
24
domain/src/main/java/com/tangem/domain/common/DomainError.kt
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package com.tangem.domain.common
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
interface DomainError {
|
||||
val code: Int
|
||||
val message: String
|
||||
val data: Any?
|
||||
}
|
||||
|
||||
open class AnyError(
|
||||
override val code: Int,
|
||||
override val message: String,
|
||||
override val data: Any? = null,
|
||||
) : DomainError
|
||||
|
||||
interface ErrorConverter<T> {
|
||||
fun convertError(error: DomainError): T
|
||||
}
|
||||
|
||||
interface Validator<Data, Error> {
|
||||
fun validate(data: Data? = null): Error?
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.tangem.domain.common
|
||||
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import timber.log.Timber
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
class FeatureCoroutineExceptionHandler {
|
||||
|
||||
// add an external logger (FbAnalytics) for handling errors
|
||||
companion object {
|
||||
fun create(from: String): CoroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
val sw = StringWriter()
|
||||
throwable.printStackTrace(PrintWriter(sw))
|
||||
val exceptionAsString: String = sw.toString()
|
||||
Timber.e("CoroutineException: from: %s, exception: %s", from, exceptionAsString)
|
||||
throw throwable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.tangem.domain.common
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
class ValueDebouncer<T>(
|
||||
var value: T?,
|
||||
private val debounce: Long = 400,
|
||||
private val onValueChanged: (T?) -> Unit
|
||||
) {
|
||||
|
||||
private val debounceScope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
private val flow = MutableStateFlow(value)
|
||||
|
||||
init {
|
||||
initFlow()
|
||||
}
|
||||
|
||||
private fun initFlow() {
|
||||
debounceScope.launch {
|
||||
flow.filter { if (value == null) true else value != it }
|
||||
.debounce(debounce)
|
||||
.onEach {
|
||||
Timber.d("onValueChanged: $it")
|
||||
onValueChanged(it)
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fun emmit(emmitValue: T) {
|
||||
debounceScope.launch { flow.emit(emmitValue) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.tangem.domain.common.extensions
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
suspend fun <T> withMainContext(block: suspend CoroutineScope.() -> T): T = withContext(Dispatchers.Main, block)
|
||||
|
||||
suspend fun <T> withIOContext(block: suspend CoroutineScope.() -> T): T = withContext(Dispatchers.IO, block)
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package com.tangem.domain.common.form
|
||||
|
||||
import com.tangem.blockchain.blockchains.ethereum.EthereumAddressService
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.common.hdWallet.DerivationPath
|
||||
import com.tangem.common.hdWallet.HDWalletError
|
||||
import com.tangem.domain.common.Validator
|
||||
import com.tangem.domain.features.addCustomToken.AddCustomTokenError
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
abstract class CustomTokenValidator<T> : Validator<T, AddCustomTokenError>
|
||||
|
||||
class StringIsEmptyValidator : CustomTokenValidator<String>() {
|
||||
override fun validate(data: String?): AddCustomTokenError? = when {
|
||||
data == null || data.isEmpty() -> null
|
||||
else -> AddCustomTokenError.FieldIsNotEmpty
|
||||
}
|
||||
}
|
||||
|
||||
class StringIsNotEmptyValidator : CustomTokenValidator<String>() {
|
||||
override fun validate(data: String?): AddCustomTokenError? = when {
|
||||
data == null || data.isEmpty() -> AddCustomTokenError.FieldIsEmpty
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
class TokenContractAddressValidator : CustomTokenValidator<String>() {
|
||||
|
||||
override fun validate(data: String?): AddCustomTokenError? = when {
|
||||
// data == null || data.isEmpty() -> AddCustomTokenError.FieldIsEmpty
|
||||
else -> EthAddressValidator().validate(data)
|
||||
}
|
||||
|
||||
private class EthAddressValidator : CustomTokenValidator<String>() {
|
||||
override fun validate(data: String?): AddCustomTokenError? {
|
||||
val isValid = EthereumAddressService().validate(data ?: "")
|
||||
return if (isValid) null else AddCustomTokenError.InvalidContractAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TokenNetworkValidator : CustomTokenValidator<Blockchain>() {
|
||||
override fun validate(data: Blockchain?): AddCustomTokenError? = when (data) {
|
||||
null, Blockchain.Unknown -> AddCustomTokenError.NetworkIsNotSelected
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
class DerivationPathValidator : CustomTokenValidator<String>() {
|
||||
override fun validate(data: String?): AddCustomTokenError? = when {
|
||||
data == null || data.isEmpty() -> null
|
||||
else -> {
|
||||
try {
|
||||
DerivationPath(data)
|
||||
null
|
||||
} catch (ex: HDWalletError) {
|
||||
AddCustomTokenError.InvalidDerivationPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
domain/src/main/java/com/tangem/domain/common/form/Form.kt
Normal file
75
domain/src/main/java/com/tangem/domain/common/form/Form.kt
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package com.tangem.domain.common.form
|
||||
|
||||
import com.tangem.common.json.MoshiJsonConverter
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
class Form(
|
||||
val fieldList: List<DataField<*>>,
|
||||
) {
|
||||
fun getField(id: FieldId): DataField<*>? = fieldList.firstOrNull { it.id == id }
|
||||
|
||||
fun getData(id: FieldId): Pair<FieldId, *>? = getField(id)?.getData()
|
||||
|
||||
// convert this form data whatever you want
|
||||
fun getData(converter: FieldDataConverter<*>) {
|
||||
fieldList.forEach { it.visitDataConverter(converter) }
|
||||
}
|
||||
}
|
||||
|
||||
interface FieldId
|
||||
|
||||
interface Field<Data> {
|
||||
val id: FieldId
|
||||
var value: Data
|
||||
val isEnabled: Boolean
|
||||
val isVisible: Boolean
|
||||
}
|
||||
|
||||
abstract class BaseDataField<Data>(
|
||||
override val id: FieldId,
|
||||
override var value: Data
|
||||
) : DataField<Data> {
|
||||
|
||||
override fun getData(): Pair<FieldId, Data> = id to value
|
||||
|
||||
override fun visitDataConverter(dataConverter: FieldDataConverter<*>) {
|
||||
dataConverter.visit(getData())
|
||||
}
|
||||
}
|
||||
|
||||
interface FieldDataConverter<Result> : DataConverterVisitor<Pair<FieldId, Any?>, Result>
|
||||
|
||||
abstract class BaseFieldDataConverter<Data>() : FieldDataConverter<Data> {
|
||||
protected val collectIds: List<FieldId> = getIdToCollect()
|
||||
|
||||
protected val collectedData: MutableMap<FieldId, Any?> = mutableMapOf()
|
||||
|
||||
override fun visit(data: Pair<FieldId, Any?>?) {
|
||||
val id = data?.first ?: return
|
||||
|
||||
if (collectIds.contains(id)) {
|
||||
collectedData[id] = data.second
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun getIdToCollect(): List<FieldId>
|
||||
}
|
||||
|
||||
abstract class FieldToJsonConverter(
|
||||
protected val jsonConverter: MoshiJsonConverter
|
||||
) : BaseFieldDataConverter<String>() {
|
||||
|
||||
override fun getConvertedData(): String = jsonConverter.toJson(collectedData)
|
||||
}
|
||||
|
||||
interface DataConverterVisitor<Visitor, Result> {
|
||||
fun visit(data: Visitor?)
|
||||
fun getConvertedData(): Result
|
||||
}
|
||||
|
||||
interface DataField<Data> : Field<Data> {
|
||||
fun getData(): Pair<FieldId, Data>
|
||||
fun visitDataConverter(dataConverter: FieldDataConverter<*>)
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.tangem.domain.features.addCustomToken
|
||||
|
||||
import com.tangem.common.services.Result
|
||||
import com.tangem.network.api.tangemTech.CoinsCheckAddressResponse
|
||||
import com.tangem.network.api.tangemTech.TangemAuthInterceptor
|
||||
import com.tangem.network.api.tangemTech.TangemTechService
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
class AddCustomTokenManager(
|
||||
private val tangemTechService: TangemTechService
|
||||
) {
|
||||
|
||||
suspend fun findContractAddress(
|
||||
contractAddress: String,
|
||||
networkId: String? = null
|
||||
): List<CoinsCheckAddressResponse.Token> {
|
||||
val result = tangemTechService.coinsCheckAddress(contractAddress, networkId)
|
||||
return when (result) {
|
||||
is Result.Success -> {
|
||||
result.data.tokens
|
||||
}
|
||||
is Result.Failure -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun attachAuthKey(authKey: String) {
|
||||
tangemTechService.addHeaderInterceptors(listOf(TangemAuthInterceptor(authKey)))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.domain.features.addCustomToken
|
||||
|
||||
import com.tangem.domain.common.AnyError
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
sealed class AddCustomTokenWarning : AnyError(0, "Add custom token - warning") {
|
||||
object PotentialScamToken : AddCustomTokenWarning()
|
||||
object TokenAlreadyAdded : AddCustomTokenWarning()
|
||||
}
|
||||
|
||||
sealed class AddCustomTokenError : AnyError(1, "Add custom token - error") {
|
||||
object NetworkIsNotSelected : AddCustomTokenError()
|
||||
object InvalidContractAddress : AddCustomTokenError()
|
||||
object FieldIsEmpty : AddCustomTokenError()
|
||||
object FieldIsNotEmpty : AddCustomTokenError()
|
||||
object InvalidDerivationPath : AddCustomTokenError()
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.domain.features.addCustomToken
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.domain.common.form.BaseDataField
|
||||
import com.tangem.domain.common.form.FieldId
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
enum class CustomTokenFieldId : FieldId {
|
||||
ContractAddress,
|
||||
Network,
|
||||
Name,
|
||||
Symbol,
|
||||
Decimals,
|
||||
DerivationPath,
|
||||
}
|
||||
|
||||
data class TokenNetworkField(
|
||||
override val id: FieldId,
|
||||
val itemList: List<Blockchain>,
|
||||
override val isEnabled: Boolean = true,
|
||||
override val isVisible: Boolean = true,
|
||||
) : BaseDataField<Blockchain>(id, Blockchain.Unknown)
|
||||
|
||||
data class TokenField(
|
||||
override val id: FieldId,
|
||||
override val isEnabled: Boolean = true,
|
||||
override val isVisible: Boolean = true,
|
||||
) : BaseDataField<String>(id, "")
|
||||
|
||||
data class TokenDerivationPathField(
|
||||
override val id: FieldId,
|
||||
override val isEnabled: Boolean = true,
|
||||
override val isVisible: Boolean = true,
|
||||
) : BaseDataField<String>(id, "")
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.tangem.domain.features.addCustomToken.redux
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.domain.common.form.FieldId
|
||||
import com.tangem.domain.features.addCustomToken.AddCustomTokenError
|
||||
import com.tangem.domain.features.addCustomToken.AddCustomTokenWarning
|
||||
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId
|
||||
import com.tangem.network.api.tangemTech.CoinsCheckAddressResponse
|
||||
import org.rekotlin.Action
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
sealed class AddCustomTokenAction : Action {
|
||||
// initializing actions
|
||||
data class SetTangemTechAuthHeader(val cardPublicKeyHex: String) : AddCustomTokenAction()
|
||||
|
||||
|
||||
// from user, ui
|
||||
object OnBackPressed : AddCustomTokenAction()
|
||||
data class OnTokenContractAddressChanged(val value: String) : AddCustomTokenAction()
|
||||
data class OnTokenNetworkChanged(val value: Blockchain) : AddCustomTokenAction()
|
||||
data class OnTokenDerivationPathChanged(val value: String) : AddCustomTokenAction()
|
||||
data class OnTokenFieldChanged(val id: FieldId, val value: String) : AddCustomTokenAction()
|
||||
|
||||
|
||||
// from redux
|
||||
object UpdateForm : AddCustomTokenAction()
|
||||
data class FillTokenFields(
|
||||
val token: CoinsCheckAddressResponse.Token,
|
||||
val contract: CoinsCheckAddressResponse.Token.Contract,
|
||||
) : AddCustomTokenAction()
|
||||
|
||||
sealed class Error : AddCustomTokenAction() {
|
||||
data class Add(val id: CustomTokenFieldId, val error: AddCustomTokenError) : Error()
|
||||
data class Remove(val id: CustomTokenFieldId) : Error()
|
||||
}
|
||||
|
||||
sealed class Warning : AddCustomTokenAction() {
|
||||
data class Add(val warning: AddCustomTokenWarning) : Warning()
|
||||
data class Remove(val warning: AddCustomTokenWarning) : Warning()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
package com.tangem.domain.features.addCustomToken.redux
|
||||
|
||||
import android.webkit.ValueCallback
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.domain.common.form.*
|
||||
import com.tangem.domain.features.addCustomToken.*
|
||||
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.*
|
||||
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.*
|
||||
import com.tangem.domain.store.BaseStoreHub
|
||||
import com.tangem.domain.store.DomainState
|
||||
import com.tangem.domain.store.dispatchOnMain
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.rekotlin.Action
|
||||
import org.rekotlin.DispatchFunction
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal object AddCustomTokenHub : BaseStoreHub<AddCustomTokensState>("AddCustomTokenHub") {
|
||||
|
||||
override val initialState: AddCustomTokensState = AddCustomTokensState()
|
||||
|
||||
override fun handle(state: () -> DomainState?, action: Action, dispatch: DispatchFunction) = when (action) {
|
||||
is OnBackPressed -> hubScope.cancel()
|
||||
else -> super.handle(state, action, dispatch)
|
||||
}
|
||||
|
||||
override suspend fun handleAction(
|
||||
state: DomainState,
|
||||
action: Action,
|
||||
dispatch: DispatchFunction,
|
||||
cancel: ValueCallback<Action>
|
||||
) {
|
||||
if (action !is AddCustomTokenAction) return
|
||||
val state = state.addCustomTokensState
|
||||
|
||||
when (action) {
|
||||
is OnTokenContractAddressChanged -> {
|
||||
val contractAddress = action.value
|
||||
val validator: TokenContractAddressValidator = getValidator(ContractAddress, state)
|
||||
val error = validator.validate(contractAddress)
|
||||
addOrRemoveError(ContractAddress, error)
|
||||
if (error != null) return
|
||||
|
||||
val manager = state.addCustomTokenManager
|
||||
val selectedNetwork: Blockchain? = getField<TokenNetworkField>(Network, state).value.let {
|
||||
if (it == Blockchain.Unknown) null else it
|
||||
}
|
||||
val foundTokens = manager.findContractAddress(contractAddress, selectedNetwork?.id)
|
||||
when {
|
||||
foundTokens.isEmpty() -> {}
|
||||
foundTokens.size == 1 -> {
|
||||
// fill and disable other fields by token info
|
||||
val token = foundTokens[0]
|
||||
dispatchOnMain(FillTokenFields(token, token.contracts[0]))
|
||||
}
|
||||
else -> {
|
||||
// show tokens list for selection
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
is OnTokenNetworkChanged -> {
|
||||
val validator: TokenNetworkValidator = getValidator(Network, state)
|
||||
addOrRemoveError(Network, validator.validate(action.value))
|
||||
}
|
||||
is OnTokenFieldChanged -> {
|
||||
val validator: StringIsNotEmptyValidator = getValidator(action.id, state)
|
||||
addOrRemoveError(action.id as CustomTokenFieldId, validator.validate(action.value))
|
||||
}
|
||||
is OnTokenDerivationPathChanged -> {
|
||||
val validator: DerivationPathValidator = getValidator(DerivationPath, state)
|
||||
addOrRemoveError(DerivationPath, validator.validate(action.value))
|
||||
}
|
||||
is FillTokenFields -> {
|
||||
val networkField = getField<TokenNetworkField>(Network, state)
|
||||
val nameField = getField<TokenField>(Name, state)
|
||||
val symbolField = getField<TokenField>(Symbol, state)
|
||||
val decimalsField = getField<TokenField>(Decimals, state)
|
||||
|
||||
val token = action.token
|
||||
val contract = action.contract
|
||||
networkField.value = Blockchain.fromId(contract.networkId)
|
||||
nameField.value = token.name
|
||||
symbolField.value = token.symbol
|
||||
decimalsField.value = contract.decimalCount.toString()
|
||||
|
||||
dispatchOnMain(UpdateForm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun addOrRemoveError(id: CustomTokenFieldId, error: AddCustomTokenError?) {
|
||||
if (error == null) {
|
||||
dispatchOnMain(Error.Remove(id))
|
||||
} else {
|
||||
dispatchOnMain(Error.Add(id, error))
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> getField(id: FieldId, state: AddCustomTokensState): T {
|
||||
return state.form.getField(id) as T
|
||||
}
|
||||
|
||||
private inline fun <reified T> getValidator(id: FieldId, state: AddCustomTokensState): T {
|
||||
return state.getValidator(id) as T
|
||||
}
|
||||
|
||||
override fun reduceAction(action: Action, state: AddCustomTokensState): AddCustomTokensState {
|
||||
return when (action) {
|
||||
is SetTangemTechAuthHeader -> {
|
||||
state.apply { addCustomTokenManager.attachAuthKey(action.cardPublicKeyHex) }
|
||||
}
|
||||
is UpdateForm -> updateFormState(state)
|
||||
is OnTokenNetworkChanged -> {
|
||||
val field: TokenNetworkField = getField(Network, state)
|
||||
field.value = action.value
|
||||
updateFormState(state)
|
||||
}
|
||||
is OnTokenContractAddressChanged -> {
|
||||
val field: TokenField = getField(ContractAddress, state)
|
||||
field.value = action.value
|
||||
updateFormState(state)
|
||||
}
|
||||
is OnTokenDerivationPathChanged -> {
|
||||
val field: TokenDerivationPathField = getField(Network, state)
|
||||
field.value = action.value
|
||||
updateFormState(state)
|
||||
}
|
||||
is OnTokenFieldChanged -> {
|
||||
val field: TokenField = getField(action.id, state)
|
||||
field.value = action.value
|
||||
updateFormState(state)
|
||||
}
|
||||
is Error.Add -> {
|
||||
val newMap = state.formErrors.toMutableMap().apply { this[action.id] = action.error }
|
||||
state.copy(formErrors = newMap)
|
||||
}
|
||||
is Error.Remove -> {
|
||||
val newMap = state.formErrors.toMutableMap().apply { remove(action.id) }
|
||||
state.copy(formErrors = newMap)
|
||||
}
|
||||
is Warning.Add -> {
|
||||
val newList = state.warnings.toMutableList().apply { add(action.warning) }
|
||||
state.copy(warnings = newList)
|
||||
}
|
||||
is Warning.Remove -> {
|
||||
val newList = state.warnings.toMutableList().apply { remove(action.warning) }
|
||||
state.copy(warnings = newList)
|
||||
}
|
||||
else -> state
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFormState(state: AddCustomTokensState): AddCustomTokensState {
|
||||
return state.copy(form = Form(state.form.fieldList))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
package com.tangem.domain.features.addCustomToken.redux
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.domain.common.form.*
|
||||
import com.tangem.domain.features.addCustomToken.*
|
||||
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.*
|
||||
import com.tangem.network.api.tangemTech.TangemTechService
|
||||
import org.rekotlin.StateType
|
||||
|
||||
data class AddCustomTokensState(
|
||||
val form: Form = Form(createFormFields()),
|
||||
val formValidators: Map<CustomTokenFieldId, CustomTokenValidator<*>> = createFormValidators(),
|
||||
val formErrors: Map<CustomTokenFieldId, AddCustomTokenError> = emptyMap(),
|
||||
val warnings: List<AddCustomTokenWarning> = emptyList(),
|
||||
val addCustomTokenManager: AddCustomTokenManager = AddCustomTokenManager(TangemTechService())
|
||||
) : StateType {
|
||||
|
||||
val completeDataType: CompleteDataType
|
||||
get() = calculateDataType()
|
||||
|
||||
fun getData(
|
||||
converter: FieldDataConverter<out CompleteData> = CompleteData.createDataConverter(completeDataType)
|
||||
): CompleteData {
|
||||
form.getData(converter)
|
||||
return converter.getConvertedData()
|
||||
}
|
||||
|
||||
fun getLockedFieldsForKnownToken(): List<CustomTokenFieldId> {
|
||||
return listOf(
|
||||
Name, Symbol, Decimals
|
||||
)
|
||||
}
|
||||
|
||||
fun getValidator(id: FieldId): CustomTokenValidator<*> = formValidators[id]!!
|
||||
|
||||
fun hasError(id: FieldId): Boolean = formErrors[id] != null
|
||||
|
||||
fun getError(id: FieldId): AddCustomTokenError? {
|
||||
return formErrors[id]
|
||||
}
|
||||
|
||||
private fun calculateDataType(): CompleteDataType {
|
||||
val idsToCheck = listOf(ContractAddress, Name, Symbol, Decimals)
|
||||
val fieldsToCheck = form.fieldList.filter { idsToCheck.contains(it.id) }
|
||||
|
||||
val isEmptyValidator = StringIsEmptyValidator()
|
||||
fieldsToCheck.map { data -> data.toString() }.forEach {
|
||||
// if one of the fields has error -> then it
|
||||
val error = isEmptyValidator.validate(it)
|
||||
if (error != null) return CompleteDataType.Token
|
||||
}
|
||||
|
||||
return CompleteDataType.Blockchain
|
||||
}
|
||||
|
||||
|
||||
companion object Utils {
|
||||
private fun createFormFields(): List<DataField<*>> {
|
||||
return listOf(
|
||||
TokenField(ContractAddress),
|
||||
TokenNetworkField(Network, getSupportedBlockchains()),
|
||||
TokenField(Name),
|
||||
TokenField(Symbol),
|
||||
TokenField(Decimals),
|
||||
TokenDerivationPathField(DerivationPath),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createFormValidators(): Map<CustomTokenFieldId, CustomTokenValidator<*>> {
|
||||
return mapOf(
|
||||
ContractAddress to TokenContractAddressValidator(),
|
||||
Network to TokenNetworkValidator(),
|
||||
Name to StringIsNotEmptyValidator(),
|
||||
Symbol to StringIsNotEmptyValidator(),
|
||||
Decimals to StringIsNotEmptyValidator(),
|
||||
DerivationPath to DerivationPathValidator(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getSupportedBlockchains(): List<Blockchain> {
|
||||
return Blockchain.values().filter { !it.isTestnet() }.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class CompleteDataType {
|
||||
Blockchain, Token
|
||||
}
|
||||
|
||||
sealed class CompleteData() {
|
||||
|
||||
companion object {
|
||||
fun createDataConverter(completeDataType: CompleteDataType): FieldDataConverter<out CompleteData> =
|
||||
when (completeDataType) {
|
||||
CompleteDataType.Blockchain -> CustomBlockchain.Converter()
|
||||
CompleteDataType.Token -> CustomToken.Converter()
|
||||
}
|
||||
}
|
||||
|
||||
class CustomBlockchain(
|
||||
val selectedNetwork: Blockchain,
|
||||
val derivationPath: String?
|
||||
) : CompleteData() {
|
||||
|
||||
class Converter : BaseFieldDataConverter<CustomBlockchain>() {
|
||||
override fun getConvertedData(): CustomBlockchain = CustomBlockchain(
|
||||
collectedData[Network] as Blockchain,
|
||||
collectedData[DerivationPath] as? String,
|
||||
)
|
||||
|
||||
override fun getIdToCollect(): List<FieldId> = listOf(Network, DerivationPath)
|
||||
}
|
||||
}
|
||||
|
||||
class CustomToken(
|
||||
val contractAddress: String,
|
||||
val selectedNetwork: Blockchain,
|
||||
val name: String,
|
||||
val tokenSymbol: String,
|
||||
val decimals: Int,
|
||||
val derivationPath: String?,
|
||||
) : CompleteData() {
|
||||
|
||||
class Converter : BaseFieldDataConverter<CustomToken>() {
|
||||
override fun getConvertedData(): CustomToken = CustomToken(
|
||||
collectedData[ContractAddress] as String,
|
||||
collectedData[Network] as Blockchain,
|
||||
collectedData[Name] as String,
|
||||
collectedData[Symbol] as String,
|
||||
collectedData[Decimals] as Int,
|
||||
collectedData[DerivationPath] as? String,
|
||||
)
|
||||
|
||||
override fun getIdToCollect(): List<FieldId> = CustomTokenFieldId.values().toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
37
domain/src/main/java/com/tangem/domain/store/DomainStore.kt
Normal file
37
domain/src/main/java/com/tangem/domain/store/DomainStore.kt
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package com.tangem.domain.store
|
||||
|
||||
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenHub
|
||||
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokensState
|
||||
import org.rekotlin.Action
|
||||
import org.rekotlin.Middleware
|
||||
import org.rekotlin.StateType
|
||||
import org.rekotlin.Store
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
private class DomainStore // for simple search
|
||||
|
||||
val domainStore = Store(
|
||||
state = DomainState(),
|
||||
middleware = domainMiddlewares(),
|
||||
reducer = { action, state -> domainReduce(action, state) }
|
||||
)
|
||||
|
||||
data class DomainState(
|
||||
val addCustomTokensState: AddCustomTokensState = AddCustomTokenHub.initialState
|
||||
) : StateType
|
||||
|
||||
private fun domainMiddlewares(): List<Middleware<DomainState>> {
|
||||
return listOf(
|
||||
AddCustomTokenHub.middleware
|
||||
)
|
||||
}
|
||||
|
||||
private fun domainReduce(action: Action, state: DomainState?): DomainState {
|
||||
requireNotNull(state)
|
||||
|
||||
return DomainState(
|
||||
addCustomTokensState = AddCustomTokenHub.reduceAction(action, state.addCustomTokensState)
|
||||
)
|
||||
}
|
||||
87
domain/src/main/java/com/tangem/domain/store/StoreHub.kt
Normal file
87
domain/src/main/java/com/tangem/domain/store/StoreHub.kt
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package com.tangem.domain.store
|
||||
|
||||
import android.webkit.ValueCallback
|
||||
import com.tangem.domain.common.FeatureCoroutineExceptionHandler
|
||||
import com.tangem.domain.common.extensions.withIOContext
|
||||
import com.tangem.domain.common.extensions.withMainContext
|
||||
import kotlinx.coroutines.*
|
||||
import org.rekotlin.Action
|
||||
import org.rekotlin.DispatchFunction
|
||||
import org.rekotlin.Middleware
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
interface StoreHub<StoreState, State> {
|
||||
val initialState: State
|
||||
val middleware: Middleware<StoreState>
|
||||
fun reduceAction(action: Action, state: State): State
|
||||
}
|
||||
|
||||
/**
|
||||
* Hub contains the entry points for actions. It processes it through middleware and reducer.
|
||||
* All action went from the middleware must be dispatched through StoreHub.dispatchOnMain(Actions)
|
||||
* and StoreHub.dispatchOnIO(Actions)
|
||||
|
||||
* Hub is the provider of an initial state of a State.
|
||||
*
|
||||
* @param name - name of the Hub
|
||||
*/
|
||||
abstract class BaseStoreHub<State>(
|
||||
private val name: String,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
) : StoreHub<DomainState, State> {
|
||||
|
||||
protected val actionsAndJobs = mutableMapOf<Action, Job>()
|
||||
protected val hubScope = CoroutineScope(
|
||||
Job() + dispatcher + CoroutineName(name) + FeatureCoroutineExceptionHandler.create(name)
|
||||
)
|
||||
|
||||
/**
|
||||
* Main entry point for the all actions
|
||||
*/
|
||||
override val middleware: Middleware<DomainState> = { dispatch, state ->
|
||||
{ next ->
|
||||
{ action ->
|
||||
handle(state, action, dispatch)
|
||||
next(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches new coroutine and stores the action with it's coroutine job. (Coroutine can be cancelled
|
||||
* through invoking the cancelActionJob() function inside a middleware).
|
||||
* Removes the action when job is completed.
|
||||
*/
|
||||
protected open fun handle(state: () -> DomainState?, action: Action, dispatch: DispatchFunction) {
|
||||
val domainState = state() ?: throw UnsupportedOperationException("State for the $name can't be NULL")
|
||||
|
||||
hubScope.launch {
|
||||
actionsAndJobs[action] = this.coroutineContext.job
|
||||
actionsAndJobs[action]?.invokeOnCompletion { actionsAndJobs.remove(action) }
|
||||
|
||||
handleAction(
|
||||
state = domainState,
|
||||
action = action,
|
||||
dispatch = dispatch,
|
||||
cancel = { actionsAndJobs.remove(it)?.cancel() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract suspend fun handleAction(
|
||||
state: DomainState,
|
||||
action: Action,
|
||||
dispatch: DispatchFunction,
|
||||
cancel: ValueCallback<Action>,
|
||||
)
|
||||
}
|
||||
|
||||
internal suspend inline fun StoreHub<*, *>.dispatchOnMain(vararg actions: Action) {
|
||||
withMainContext { actions.forEach { domainStore.dispatch(it) } }
|
||||
}
|
||||
|
||||
internal suspend inline fun StoreHub<*, *>.dispatchOnIO(vararg actions: Action) {
|
||||
withIOContext { actions.forEach { domainStore.dispatch(it) } }
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.domain.features
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue