Updated on 2026-08-14
This commit is contained in:
parent
e5408a2ac7
commit
89fbfa5971
20 changed files with 828 additions and 39 deletions
|
|
@ -11,14 +11,18 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
|
|||
import com.tangem.domain.models.scan.serialization.*
|
||||
import com.tangem.domain.visa.model.VisaActivationRemoteState
|
||||
import com.tangem.domain.visa.model.VisaCardActivationStatus
|
||||
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
|
||||
import com.tangem.domain.wallets.legacy.UserWalletsListManager
|
||||
import com.tangem.domain.core.wallets.UserWalletsListRepository
|
||||
import com.tangem.sdk.storage.AndroidSecureStorage
|
||||
import com.tangem.sdk.storage.AndroidSecureStorageV2
|
||||
import com.tangem.sdk.storage.createEncryptedSharedPreferences
|
||||
import com.tangem.tap.domain.userWalletList.implementation.BiometricUserWalletsListManager
|
||||
import com.tangem.tap.domain.userWalletList.implementation.GeneralUserWalletsListManager
|
||||
import com.tangem.tap.domain.userWalletList.implementation.RuntimeUserWalletsListManager
|
||||
import com.tangem.tap.domain.userWalletList.repository.DefaultUserWalletsListRepository
|
||||
import com.tangem.tap.domain.userWalletList.repository.DelegatedKeystoreManager
|
||||
import com.tangem.tap.domain.userWalletList.repository.UserWalletEncryptionKeysRepository
|
||||
import com.tangem.tap.domain.userWalletList.repository.UserWalletsKeysStoreDecorator
|
||||
import com.tangem.tap.domain.userWalletList.repository.implementation.BiometricUserWalletsKeysRepository
|
||||
import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultSelectedUserWalletRepository
|
||||
|
|
@ -26,6 +30,7 @@ import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultUse
|
|||
import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultUserWalletsSensitiveInformationRepository
|
||||
import com.tangem.tap.tangemSdkManager
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.ProviderSuspend
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
|
@ -40,6 +45,7 @@ internal object UserWalletsListManagerModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Deprecated("Use UserWalletsListRepository instead")
|
||||
fun provideGeneralUserWalletsListManager(
|
||||
@ApplicationContext applicationContext: Context,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
|
|
@ -58,42 +64,14 @@ internal object UserWalletsListManagerModule {
|
|||
)
|
||||
}
|
||||
|
||||
@Deprecated("Use UserWalletsListRepository instead")
|
||||
private fun createBiometricUserWalletsListManager(
|
||||
applicationContext: Context,
|
||||
analyticsEventHandler: AnalyticsEventHandler,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): UserWalletsListManager {
|
||||
val moshi = Moshi.Builder()
|
||||
.add(WalletDerivedKeysMapAdapter())
|
||||
.add(ScanResponseDerivedKeysMapAdapter())
|
||||
.add(ByteArrayKeyAdapter())
|
||||
.add(ExtendedPublicKeysMapAdapter())
|
||||
.add(CardBackupStatusAdapter())
|
||||
.add(DerivationPathAdapterWithMigration())
|
||||
.add(TangemSdkAdapter.DateAdapter())
|
||||
.add(TangemSdkAdapter.DerivationNodeAdapter())
|
||||
.add(TangemSdkAdapter.FirmwareVersionAdapter()) // For PrimaryCard model
|
||||
.add(VisaActivationRemoteState.jsonAdapter)
|
||||
.add(VisaCardActivationStatus.jsonAdapter)
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
val secureStorage = AndroidSecureStorage(
|
||||
preferences = SecureStorage.createEncryptedSharedPreferences(
|
||||
context = applicationContext,
|
||||
storageName = "user_wallets_storage",
|
||||
),
|
||||
androidSecureStorageV2 = AndroidSecureStorageV2(
|
||||
appContext = applicationContext,
|
||||
useStrongBox = true,
|
||||
name = "user_wallets_storage2",
|
||||
),
|
||||
androidSecureStorageV3 = AndroidSecureStorageV2(
|
||||
appContext = applicationContext,
|
||||
useStrongBox = false,
|
||||
name = "user_wallets_storage3",
|
||||
),
|
||||
)
|
||||
val moshi = buildMoshi()
|
||||
val secureStorage = buildSecureStorage(applicationContext = applicationContext)
|
||||
|
||||
val authenticatedStorage = AuthenticatedStorage(
|
||||
secureStorage = UserWalletsKeysStoreDecorator(
|
||||
|
|
@ -134,4 +112,94 @@ internal object UserWalletsListManagerModule {
|
|||
selectedUserWalletRepository = selectedUserWalletRepository,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUserWalletsListRepository(
|
||||
@ApplicationContext applicationContext: Context,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
passwordRequester: HotWalletPasswordRequester,
|
||||
): UserWalletsListRepository {
|
||||
val moshi = buildMoshi()
|
||||
val secureStorage = buildSecureStorage(applicationContext = applicationContext)
|
||||
|
||||
val authenticatedStorage = AuthenticatedStorage(
|
||||
secureStorage = UserWalletsKeysStoreDecorator(
|
||||
featureStorage = secureStorage,
|
||||
cardSdkStorageProvider = Provider { tangemSdkManager.secureStorage },
|
||||
),
|
||||
keystoreManager = DelegatedKeystoreManager(
|
||||
keystoreManagerProvider = Provider { tangemSdkManager.keystoreManager },
|
||||
),
|
||||
)
|
||||
|
||||
val publicInformationRepository = DefaultUserWalletsPublicInformationRepository(
|
||||
moshi = moshi,
|
||||
secureStorage = secureStorage,
|
||||
)
|
||||
|
||||
val sensitiveInformationRepository = DefaultUserWalletsSensitiveInformationRepository(
|
||||
moshi = moshi,
|
||||
secureStorage = secureStorage,
|
||||
)
|
||||
|
||||
val selectedUserWalletRepository = DefaultSelectedUserWalletRepository(
|
||||
secureStorage = secureStorage,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
|
||||
val userWalletEncryptionKeysRepository = UserWalletEncryptionKeysRepository(
|
||||
moshi = moshi,
|
||||
authenticatedStorage = authenticatedStorage,
|
||||
dispatchers = dispatchers,
|
||||
secureStorage = secureStorage,
|
||||
)
|
||||
|
||||
return DefaultUserWalletsListRepository(
|
||||
publicInformationRepository = publicInformationRepository,
|
||||
sensitiveInformationRepository = sensitiveInformationRepository,
|
||||
selectedUserWalletRepository = selectedUserWalletRepository,
|
||||
passwordRequester = passwordRequester,
|
||||
userWalletEncryptionKeysRepository = userWalletEncryptionKeysRepository,
|
||||
tangemSdkManagerProvider = Provider { tangemSdkManager },
|
||||
savePersistentInformation = ProviderSuspend { true }, // Always save persistent information for now
|
||||
// TODO add a settings toggle to disable saving persistent information
|
||||
)
|
||||
}
|
||||
|
||||
fun buildMoshi(): Moshi {
|
||||
return Moshi.Builder()
|
||||
.add(WalletDerivedKeysMapAdapter())
|
||||
.add(ScanResponseDerivedKeysMapAdapter())
|
||||
.add(ByteArrayKeyAdapter())
|
||||
.add(ExtendedPublicKeysMapAdapter())
|
||||
.add(CardBackupStatusAdapter())
|
||||
.add(DerivationPathAdapterWithMigration())
|
||||
.add(TangemSdkAdapter.DateAdapter())
|
||||
.add(TangemSdkAdapter.DerivationNodeAdapter())
|
||||
.add(TangemSdkAdapter.FirmwareVersionAdapter()) // For PrimaryCard model
|
||||
.add(VisaActivationRemoteState.jsonAdapter)
|
||||
.add(VisaCardActivationStatus.jsonAdapter)
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
}
|
||||
|
||||
fun buildSecureStorage(@ApplicationContext applicationContext: Context): SecureStorage {
|
||||
return AndroidSecureStorage(
|
||||
preferences = SecureStorage.createEncryptedSharedPreferences(
|
||||
context = applicationContext,
|
||||
storageName = "user_wallets_storage",
|
||||
),
|
||||
androidSecureStorageV2 = AndroidSecureStorageV2(
|
||||
appContext = applicationContext,
|
||||
useStrongBox = true,
|
||||
name = "user_wallets_storage2",
|
||||
),
|
||||
androidSecureStorageV3 = AndroidSecureStorageV2(
|
||||
appContext = applicationContext,
|
||||
useStrongBox = false,
|
||||
name = "user_wallets_storage3",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
package com.tangem.tap.domain.userWalletList.repository
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.raise.either
|
||||
import arrow.core.right
|
||||
import com.tangem.common.doOnFailure
|
||||
import com.tangem.common.doOnSuccess
|
||||
import com.tangem.common.flatMap
|
||||
import com.tangem.common.map
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.models.wallet.isLocked
|
||||
import com.tangem.domain.wallets.R
|
||||
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
|
||||
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
|
||||
import com.tangem.domain.core.wallets.error.DeleteWalletError
|
||||
import com.tangem.domain.core.wallets.error.LockWalletsError
|
||||
import com.tangem.domain.core.wallets.error.SaveWalletError
|
||||
import com.tangem.domain.core.wallets.error.SelectWalletError
|
||||
import com.tangem.domain.core.wallets.error.SetLockError
|
||||
import com.tangem.domain.core.wallets.error.UnlockWalletError
|
||||
import com.tangem.domain.core.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.core.wallets.UserWalletsListRepository.LockMethod
|
||||
import com.tangem.sdk.api.TangemSdkManager
|
||||
import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey
|
||||
import com.tangem.tap.domain.userWalletList.utils.encryptionKey
|
||||
import com.tangem.tap.domain.userWalletList.utils.lock
|
||||
import com.tangem.tap.domain.userWalletList.utils.toUserWallets
|
||||
import com.tangem.tap.domain.userWalletList.utils.updateWith
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.ProviderSuspend
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultUserWalletsListRepository(
|
||||
private val publicInformationRepository: UserWalletsPublicInformationRepository,
|
||||
private val sensitiveInformationRepository: UserWalletsSensitiveInformationRepository,
|
||||
private val selectedUserWalletRepository: SelectedUserWalletRepository,
|
||||
private val passwordRequester: HotWalletPasswordRequester,
|
||||
private val userWalletEncryptionKeysRepository: UserWalletEncryptionKeysRepository,
|
||||
private val tangemSdkManagerProvider: Provider<TangemSdkManager>,
|
||||
private val savePersistentInformation: ProviderSuspend<Boolean>,
|
||||
) : UserWalletsListRepository {
|
||||
|
||||
override val userWallets = MutableStateFlow<List<UserWallet>?>(null)
|
||||
override val selectedUserWallet = MutableStateFlow<UserWallet?>(null)
|
||||
|
||||
override suspend fun load() {
|
||||
if (userWallets.value != null) return
|
||||
|
||||
if (savePersistentInformation().not()) {
|
||||
// If we don't save persistent information, we don't need to load user wallets
|
||||
// and we should clear any existing data
|
||||
clearPersistentData()
|
||||
userWallets.value = emptyList()
|
||||
return
|
||||
}
|
||||
|
||||
val unsecuredEncryptionKeys = userWalletEncryptionKeysRepository.getAllUnsecured()
|
||||
|
||||
publicInformationRepository.getAll()
|
||||
.map { it.toUserWallets() }
|
||||
.flatMap { wallets ->
|
||||
sensitiveInformationRepository.getAll(unsecuredEncryptionKeys)
|
||||
.map { wallets.updateWith(it) }
|
||||
}.doOnSuccess {
|
||||
userWallets.value = it
|
||||
}
|
||||
|
||||
val selectedUserWalletId = selectedUserWalletRepository.get()
|
||||
selectedUserWallet.value = userWallets.value?.firstOrNull { it.walletId == selectedUserWalletId }
|
||||
?: userWallets.value?.firstOrNull()
|
||||
}
|
||||
|
||||
override suspend fun userWalletsSync(): List<UserWallet> {
|
||||
load()
|
||||
return userWallets.value!!
|
||||
}
|
||||
|
||||
override suspend fun selectedUserWalletSync(): UserWallet? {
|
||||
load()
|
||||
return selectedUserWallet.value
|
||||
}
|
||||
|
||||
override suspend fun select(userWalletId: UserWalletId): Either<SelectWalletError, UserWallet> = either {
|
||||
val userWallet = userWallets.value?.find { it.walletId == userWalletId }
|
||||
?: raise(SelectWalletError.UnableToSelectUserWallet)
|
||||
selectedUserWalletRepository.set(userWalletId)
|
||||
selectedUserWallet.value = userWallet
|
||||
userWallet
|
||||
}
|
||||
|
||||
override suspend fun saveWithoutLock(
|
||||
userWallet: UserWallet,
|
||||
canOverride: Boolean,
|
||||
): Either<SaveWalletError, UserWallet> = either {
|
||||
if (canOverride.not() && userWallets.value?.any { it.walletId == userWallet.walletId } == true) {
|
||||
raise(SaveWalletError.WalletAlreadySaved(messageId = R.string.user_wallet_list_error_wallet_already_saved))
|
||||
}
|
||||
|
||||
if (savePersistentInformation()) {
|
||||
publicInformationRepository.save(userWallet, canOverride)
|
||||
if (userWallet.isLocked.not()) {
|
||||
sensitiveInformationRepository.save(userWallet, userWallet.encryptionKey)
|
||||
}
|
||||
}
|
||||
|
||||
// update the userWallets state and add if it doesn't exist
|
||||
userWallets.update { currentWallets ->
|
||||
val wallets = currentWallets ?: emptyList()
|
||||
if (wallets.any { it.walletId == userWallet.walletId }) {
|
||||
wallets.map { if (it.walletId == userWallet.walletId) userWallet else it }
|
||||
} else {
|
||||
wallets + userWallet
|
||||
}
|
||||
}
|
||||
|
||||
// update the selectedUserWallet state if it is the only wallet
|
||||
if (userWallets.value?.size == 1) {
|
||||
selectedUserWalletRepository.set(userWallet.walletId)
|
||||
selectedUserWallet.value = userWallet
|
||||
}
|
||||
|
||||
userWallet
|
||||
}
|
||||
|
||||
override suspend fun setLock(userWalletId: UserWalletId, lockMethod: LockMethod): Either<SetLockError, Unit> =
|
||||
either {
|
||||
val userWallet = userWallets.value?.find { it.walletId == userWalletId }
|
||||
?: raise(SetLockError.UserWalletNotFound)
|
||||
|
||||
val encryptionKey = userWallet.encryptionKey
|
||||
?: raise(SetLockError.UserWalletLocked)
|
||||
|
||||
runCatching {
|
||||
userWalletEncryptionKeysRepository.save(
|
||||
encryptionKey = UserWalletEncryptionKey(
|
||||
walletId = userWalletId,
|
||||
encryptionKey = encryptionKey,
|
||||
),
|
||||
method = when (lockMethod) {
|
||||
is LockMethod.AccessCode -> {
|
||||
UserWalletEncryptionKeysRepository.EncryptionMethod.Password(lockMethod.accessCode)
|
||||
}
|
||||
LockMethod.Biometric -> {
|
||||
UserWalletEncryptionKeysRepository.EncryptionMethod.Biometric
|
||||
}
|
||||
LockMethod.NoLock -> {
|
||||
if (userWallet is UserWallet.Cold) {
|
||||
raise(SetLockError.UserWalletNotFound)
|
||||
}
|
||||
|
||||
UserWalletEncryptionKeysRepository.EncryptionMethod.Unsecured
|
||||
}
|
||||
},
|
||||
)
|
||||
}.onFailure { raise(SetLockError.UnableToSetLock(it)) }
|
||||
}
|
||||
|
||||
override suspend fun delete(userWalletIds: List<UserWalletId>): Either<DeleteWalletError, Unit> = either {
|
||||
if (userWalletIds.isEmpty()) return Unit.right()
|
||||
|
||||
publicInformationRepository.delete(userWalletIds)
|
||||
.doOnFailure {
|
||||
raise(DeleteWalletError.UnableToDelete)
|
||||
}
|
||||
sensitiveInformationRepository.delete(userWalletIds)
|
||||
.doOnFailure {
|
||||
raise(DeleteWalletError.UnableToDelete)
|
||||
}
|
||||
|
||||
userWalletEncryptionKeysRepository.delete(userWalletIds)
|
||||
|
||||
userWallets.update { currentWallets ->
|
||||
currentWallets?.filterNot { it.walletId in userWalletIds }
|
||||
}
|
||||
|
||||
selectedUserWallet.update { currentSelected ->
|
||||
if (currentSelected == null) return@update null
|
||||
|
||||
userWallets.value?.findAvailableUserWallet(
|
||||
userWallets.value?.indexOfFirst { it.walletId == currentSelected.walletId } ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unlock(
|
||||
userWalletId: UserWalletId,
|
||||
unlockMethod: UserWalletsListRepository.UnlockMethod,
|
||||
): Either<UnlockWalletError, Unit> = either {
|
||||
val userWallet = userWallets.value?.find { it.walletId == userWalletId }
|
||||
?: raise(UnlockWalletError.UserWalletNotFound)
|
||||
|
||||
if (userWallet.isLocked.not()) {
|
||||
raise(UnlockWalletError.AlreadyUnlocked)
|
||||
}
|
||||
|
||||
when (unlockMethod) {
|
||||
UserWalletsListRepository.UnlockMethod.Biometric -> {
|
||||
unlockAllWallets()
|
||||
select(userWalletId)
|
||||
}
|
||||
UserWalletsListRepository.UnlockMethod.AccessCode -> {
|
||||
if (userWallet !is UserWallet.Hot) {
|
||||
raise(UnlockWalletError.UnableToUnlock)
|
||||
}
|
||||
|
||||
val encryptionKey = requestPasswordRecursive(
|
||||
block = { password ->
|
||||
runCatching {
|
||||
userWalletEncryptionKeysRepository.getEncryptedWithPassword(userWalletId, password)
|
||||
}.onFailure {
|
||||
raise(UnlockWalletError.UnableToUnlock)
|
||||
}.getOrNull()
|
||||
},
|
||||
biometryFallback = {
|
||||
unlock(userWalletId, UserWalletsListRepository.UnlockMethod.Biometric)
|
||||
},
|
||||
).bind()
|
||||
|
||||
if (encryptionKey == null) {
|
||||
return@either
|
||||
}
|
||||
|
||||
sensitiveInformationRepository.getAll(listOf(encryptionKey))
|
||||
.doOnSuccess { userWallets.value?.updateWith(it) }
|
||||
.doOnFailure { error ->
|
||||
raise(UnlockWalletError.UnableToUnlock)
|
||||
}
|
||||
}
|
||||
UserWalletsListRepository.UnlockMethod.Scan -> {
|
||||
if (userWallet !is UserWallet.Cold) {
|
||||
raise(UnlockWalletError.UnableToUnlock)
|
||||
}
|
||||
|
||||
tangemSdkManagerProvider().scanProduct()
|
||||
.doOnSuccess { scanResponse ->
|
||||
val expectedId = UserWalletIdBuilder.scanResponse(scanResponse).build()
|
||||
|
||||
if (expectedId != userWallet.walletId) {
|
||||
raise(UnlockWalletError.ScannedCardWalletNotMatched)
|
||||
}
|
||||
|
||||
saveWithoutLock(userWallet.copy(scanResponse = scanResponse), canOverride = true)
|
||||
.mapLeft { UnlockWalletError.UnableToUnlock }
|
||||
.bind()
|
||||
}
|
||||
.doOnFailure {
|
||||
raise(UnlockWalletError.UserCancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unlockAllWallets(): Either<UnlockWalletError, Unit> = either {
|
||||
val biometricKeys = runCatching {
|
||||
userWalletEncryptionKeysRepository.getAllBiometric()
|
||||
}.getOrElse {
|
||||
// TODO handle error properly [REDACTED_TASK_KEY]
|
||||
raise(UnlockWalletError.UserCancelled)
|
||||
}
|
||||
|
||||
val unsecuredKeys = userWalletEncryptionKeysRepository.getAllUnsecured()
|
||||
val allKeys = biometricKeys + unsecuredKeys
|
||||
sensitiveInformationRepository.getAll(allKeys)
|
||||
.doOnSuccess { userWallets.value?.updateWith(it) }
|
||||
}
|
||||
|
||||
override suspend fun lockAllWallets(): Either<LockWalletsError, Unit> = either {
|
||||
val unsecuredWalletIds = userWalletEncryptionKeysRepository.getAllUnsecured().map { it.walletId }.toSet()
|
||||
|
||||
if (unsecuredWalletIds.size == userWallets.value?.size) {
|
||||
raise(LockWalletsError.NothingToLock)
|
||||
}
|
||||
|
||||
userWallets.update {
|
||||
it?.map {
|
||||
if (it.walletId !in unsecuredWalletIds) {
|
||||
it.lock()
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearPersistentData() {
|
||||
publicInformationRepository.clear()
|
||||
sensitiveInformationRepository.clear()
|
||||
userWalletEncryptionKeysRepository.clear()
|
||||
}
|
||||
|
||||
private suspend fun requestPasswordRecursive(
|
||||
block: suspend (CharArray) -> UserWalletEncryptionKey?,
|
||||
biometryFallback: suspend () -> Either<UnlockWalletError, Unit>,
|
||||
): Either<UnlockWalletError, UserWalletEncryptionKey?> {
|
||||
val result = passwordRequester.requestPassword(
|
||||
hasBiometry = tangemSdkManagerProvider.invoke().needEnrollBiometrics,
|
||||
)
|
||||
|
||||
return when (result) {
|
||||
HotWalletPasswordRequester.Result.Dismiss -> {
|
||||
passwordRequester.dismiss()
|
||||
UnlockWalletError.UserCancelled.left()
|
||||
}
|
||||
is HotWalletPasswordRequester.Result.EnteredPassword -> {
|
||||
val decrypted = block(result.password.value)
|
||||
if (decrypted == null) {
|
||||
passwordRequester.wrongPassword()
|
||||
requestPasswordRecursive(block, biometryFallback)
|
||||
} else {
|
||||
passwordRequester.successfulAuthentication()
|
||||
decrypted.right()
|
||||
}
|
||||
}
|
||||
HotWalletPasswordRequester.Result.UseBiometry -> {
|
||||
biometryFallback()
|
||||
.onRight {
|
||||
passwordRequester.successfulAuthentication()
|
||||
}
|
||||
passwordRequester.dismiss()
|
||||
null.right()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest available wallet that can be selected
|
||||
*
|
||||
* Example:
|
||||
* Number with *n* is previous selected wallet with index [prevSelectedIndex].
|
||||
*
|
||||
* 1. [*1*, 2, 3, 4] => delete 1 => [2, 3, 4] => find and select => [*2*, 3, 4]
|
||||
* 2. [1, *2*, 3, 4] => delete 2 => [1, 3, 4] => find and select => [1, *3*, 4]
|
||||
* 3. [1, 2, *3*, 4] => delete 3 => [1, 2, 4] => find and select => [1, 2, *4*]
|
||||
* 4. [1, 2, 3, *4*] => delete 4 => [1, 2, 3] => find and select => [1, 2, *3*]
|
||||
*
|
||||
* @receiver list of user wallets without deleted wallet
|
||||
*/
|
||||
private fun List<UserWallet>.findAvailableUserWallet(prevSelectedIndex: Int): UserWallet? {
|
||||
if (prevSelectedIndex == 0) return firstOrNull { !it.isLocked } ?: firstOrNull()
|
||||
|
||||
if (prevSelectedIndex in indices && !this[prevSelectedIndex].isLocked) return this[prevSelectedIndex]
|
||||
|
||||
for (offset in 1..size) {
|
||||
val rightIndex = prevSelectedIndex + offset
|
||||
if (rightIndex in indices && !this[rightIndex].isLocked) return this[rightIndex]
|
||||
|
||||
val leftIndex = prevSelectedIndex - offset
|
||||
if (leftIndex in indices && !this[leftIndex].isLocked) return this[leftIndex]
|
||||
}
|
||||
|
||||
return lastOrNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
package com.tangem.tap.domain.userWalletList.repository
|
||||
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import com.tangem.common.authentication.storage.AuthenticatedStorage
|
||||
import com.tangem.common.services.secure.SecureStorage
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.hot.sdk.android.crypto.AESEncryptionProtocol
|
||||
import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal class UserWalletEncryptionKeysRepository(
|
||||
moshi: Moshi,
|
||||
private val authenticatedStorage: AuthenticatedStorage,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val secureStorage: SecureStorage,
|
||||
) {
|
||||
|
||||
private val encryptionKeyAdapter: JsonAdapter<UserWalletEncryptionKey> = moshi.adapter(
|
||||
UserWalletEncryptionKey::class.java,
|
||||
)
|
||||
private val userWalletsIdsListAdapter: JsonAdapter<List<UserWalletId>> = moshi.adapter(
|
||||
Types.newParameterizedType(List::class.java, UserWalletId::class.java),
|
||||
)
|
||||
|
||||
suspend fun save(encryptionKey: UserWalletEncryptionKey, method: EncryptionMethod) = withContext(dispatchers.io) {
|
||||
secureStorage.delete(StorageKey.UserWalletEncryptionKeyUnsecured(encryptionKey.walletId).name)
|
||||
|
||||
when (method) {
|
||||
EncryptionMethod.Unsecured -> {
|
||||
secureStorage.store(
|
||||
account = StorageKey.UserWalletEncryptionKeyUnsecured(encryptionKey.walletId).name,
|
||||
data = encryptionKey.encode(),
|
||||
)
|
||||
}
|
||||
EncryptionMethod.Biometric -> {
|
||||
authenticatedStorage.store(
|
||||
keyAlias = StorageKey.UserWalletEncryptionKey(encryptionKey.walletId).name,
|
||||
data = encryptionKey.encode(),
|
||||
)
|
||||
}
|
||||
is EncryptionMethod.Password -> {
|
||||
val encodedWithPass = AESEncryptionProtocol.encryptWithPassword(
|
||||
password = method.password,
|
||||
content = encryptionKey.encode(),
|
||||
)
|
||||
secureStorage.store(
|
||||
account = StorageKey.UserWalletEncryptionKeyEncrypted(encryptionKey.walletId).name,
|
||||
data = encodedWithPass,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
storeUserWalletId(userWalletId = encryptionKey.walletId)
|
||||
}
|
||||
|
||||
suspend fun getAllUnsecured(): List<UserWalletEncryptionKey> = withContext(dispatchers.io) {
|
||||
getUserWalletsIds().mapNotNull { userWalletId ->
|
||||
secureStorage.get(account = StorageKey.UserWalletEncryptionKeyUnsecured(userWalletId).name).decodeToKey()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getEncryptedWithPassword(userWalletId: UserWalletId, password: CharArray): UserWalletEncryptionKey? {
|
||||
val encrypted = secureStorage.get(
|
||||
account = StorageKey.UserWalletEncryptionKeyEncrypted(userWalletId).name,
|
||||
) ?: return null
|
||||
|
||||
val decrypted = AESEncryptionProtocol.decryptWithPassword(password, encrypted)
|
||||
|
||||
return decrypted.decodeToKey()
|
||||
}
|
||||
|
||||
suspend fun getAllBiometric(): List<UserWalletEncryptionKey> = withContext(dispatchers.io) {
|
||||
val keys = getUserWalletsIds().map { userWalletId ->
|
||||
StorageKey.UserWalletEncryptionKey(userWalletId).name
|
||||
}
|
||||
|
||||
authenticatedStorage.get(keys).mapNotNull {
|
||||
it.value.decodeToKey()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(userWalletIds: List<UserWalletId>) {
|
||||
if (userWalletIds.isEmpty()) return
|
||||
|
||||
withContext(dispatchers.io) {
|
||||
userWalletIds.forEach { userWalletId ->
|
||||
secureStorage.delete(StorageKey.UserWalletEncryptionKeyUnsecured(userWalletId).name)
|
||||
secureStorage.delete(StorageKey.UserWalletEncryptionKeyEncrypted(userWalletId).name)
|
||||
authenticatedStorage.delete(StorageKey.UserWalletEncryptionKey(userWalletId).name)
|
||||
}
|
||||
|
||||
val userWalletsIds = getUserWalletsIds().filterNot { it in userWalletIds }
|
||||
secureStorage.store(userWalletsIds.encode(), StorageKey.UserWalletIds.name)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
withContext(dispatchers.io) {
|
||||
val userWalletsIds = getUserWalletsIds()
|
||||
userWalletsIds.forEach { userWalletId ->
|
||||
secureStorage.delete(StorageKey.UserWalletEncryptionKeyUnsecured(userWalletId).name)
|
||||
secureStorage.delete(StorageKey.UserWalletEncryptionKeyEncrypted(userWalletId).name)
|
||||
authenticatedStorage.delete(StorageKey.UserWalletEncryptionKey(userWalletId).name)
|
||||
}
|
||||
secureStorage.delete(StorageKey.UserWalletIds.name)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getUserWalletsIds(): List<UserWalletId> {
|
||||
return withContext(dispatchers.io) {
|
||||
secureStorage.get(StorageKey.UserWalletIds.name)
|
||||
.decodeToUserWalletsIds()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeUserWalletId(userWalletId: UserWalletId) {
|
||||
val userWalletIds = (getUserWalletsIds() + userWalletId).distinct()
|
||||
|
||||
withContext(dispatchers.io) {
|
||||
secureStorage.store(userWalletIds.encode(), StorageKey.UserWalletIds.name)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun UserWalletEncryptionKey.encode(): ByteArray {
|
||||
return withContext(dispatchers.default) {
|
||||
this@encode
|
||||
.let(encryptionKeyAdapter::toJson)
|
||||
.encodeToByteArray(throwOnInvalidSequence = true)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ByteArray?.decodeToKey(): UserWalletEncryptionKey? {
|
||||
return withContext(dispatchers.default) {
|
||||
this@decodeToKey
|
||||
?.decodeToString(throwOnInvalidSequence = true)
|
||||
?.let(encryptionKeyAdapter::fromJson)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun List<UserWalletId>.encode(): ByteArray {
|
||||
return withContext(dispatchers.default) {
|
||||
this@encode
|
||||
.let(userWalletsIdsListAdapter::toJson)
|
||||
.encodeToByteArray(throwOnInvalidSequence = true)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ByteArray?.decodeToUserWalletsIds(): List<UserWalletId> {
|
||||
return withContext(dispatchers.default) {
|
||||
this@decodeToUserWalletsIds
|
||||
?.decodeToString(throwOnInvalidSequence = true)
|
||||
?.let(userWalletsIdsListAdapter::fromJson)
|
||||
.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
sealed class EncryptionMethod {
|
||||
data object Unsecured : EncryptionMethod()
|
||||
data object Biometric : EncryptionMethod()
|
||||
class Password(val password: CharArray) : EncryptionMethod()
|
||||
}
|
||||
|
||||
private sealed interface StorageKey {
|
||||
val name: String
|
||||
|
||||
class UserWalletEncryptionKeyUnsecured(userWalletId: UserWalletId) : StorageKey {
|
||||
override val name: String = "user_wallet_encryption_key_unsecured_${userWalletId.stringValue}"
|
||||
}
|
||||
|
||||
class UserWalletEncryptionKey(userWalletId: UserWalletId) : StorageKey {
|
||||
override val name: String = "user_wallet_encryption_key_${userWalletId.stringValue}"
|
||||
}
|
||||
|
||||
class UserWalletEncryptionKeyEncrypted(userWalletId: UserWalletId) : StorageKey {
|
||||
override val name: String = "user_wallet_encryption_key_encrypted_${userWalletId.stringValue}"
|
||||
}
|
||||
|
||||
object UserWalletIds : StorageKey {
|
||||
override val name: String = "user_wallets_ids_with_saved_keys"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ dependencies {
|
|||
api(deps.kotlin.coroutines)
|
||||
api(deps.arrow.core)
|
||||
api(deps.arrow.fx)
|
||||
api(projects.domain.models)
|
||||
|
||||
implementation(deps.kotlin.serialization)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
package com.tangem.domain.core.wallets
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.core.wallets.error.DeleteWalletError
|
||||
import com.tangem.domain.core.wallets.error.LockWalletsError
|
||||
import com.tangem.domain.core.wallets.error.SaveWalletError
|
||||
import com.tangem.domain.core.wallets.error.SelectWalletError
|
||||
import com.tangem.domain.core.wallets.error.SetLockError
|
||||
import com.tangem.domain.core.wallets.error.UnlockWalletError
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Repository for managing user wallets list.
|
||||
* It provides methods to load, select, save, lock, unlock, and delete user wallets.
|
||||
*
|
||||
* TODO tests [REDACTED_TASK_KEY]
|
||||
*
|
||||
* @see com.tangem.domain.models.wallet.UserWallet
|
||||
* @see com.tangem.domain.models.wallet.UserWalletId
|
||||
*/
|
||||
interface UserWalletsListRepository {
|
||||
|
||||
/**
|
||||
* List of user wallets.
|
||||
* It can be null if the list is not loaded yet.
|
||||
*/
|
||||
val userWallets: StateFlow<List<UserWallet>?>
|
||||
|
||||
/**
|
||||
* Currently selected user wallet.
|
||||
* It can be null if wallets list is not loaded yet or wallets list is empty.
|
||||
*/
|
||||
val selectedUserWallet: StateFlow<UserWallet?>
|
||||
|
||||
/**
|
||||
* Loads user wallets list and selected wallet.
|
||||
* If the list is already loaded, it does nothing.
|
||||
*/
|
||||
suspend fun load()
|
||||
|
||||
/**
|
||||
* Gets and if necessary loads user wallets list and selected wallet.
|
||||
*/
|
||||
suspend fun userWalletsSync(): List<UserWallet>
|
||||
|
||||
/**
|
||||
* Gets and if necessary loads selected user wallet.
|
||||
*/
|
||||
suspend fun selectedUserWalletSync(): UserWallet?
|
||||
|
||||
/**
|
||||
* Selects user wallet by id.
|
||||
* If the wallet is not found, it returns [SelectWalletError.UnableToSelectUserWallet].
|
||||
*/
|
||||
suspend fun select(userWalletId: UserWalletId): Either<SelectWalletError, UserWallet>
|
||||
|
||||
/**
|
||||
* Saves user wallet.
|
||||
* If the wallet already exists and [canOverride] is false, it returns [SaveWalletError.WalletAlreadySaved].
|
||||
* If the wallet already exists and [canOverride] is true, it overrides the existing wallet.
|
||||
*
|
||||
* Does not lock the wallet after saving, it should be done manually using [setLock] method.
|
||||
*/
|
||||
suspend fun saveWithoutLock(
|
||||
userWallet: UserWallet,
|
||||
canOverride: Boolean = true,
|
||||
): Either<SaveWalletError, UserWallet>
|
||||
|
||||
/**
|
||||
* Sets lock for **unlocked** user wallet.
|
||||
* If the wallet is not found, it returns [SetLockError.UserWalletNotFound]
|
||||
* If the wallet is locked, it returns [SetLockError.UserWalletLocked]
|
||||
* If the lock method is not supported, it returns [SetLockError.UnableToSetLock].
|
||||
*/
|
||||
suspend fun setLock(userWalletId: UserWalletId, lockMethod: LockMethod): Either<SetLockError, Unit>
|
||||
|
||||
/**
|
||||
* Deletes user wallets by ids.
|
||||
* If the wallet is not found, it returns [DeleteWalletError.UnableToDelete]
|
||||
*/
|
||||
suspend fun delete(userWalletIds: List<UserWalletId>): Either<DeleteWalletError, Unit>
|
||||
|
||||
/**
|
||||
* Unlocks specific user wallet.
|
||||
* If the wallet is already unlocked, returns [UnlockWalletError.AlreadyUnlocked].
|
||||
* If the wallet is not found, returns [UnlockWalletError.UserWalletNotFound].
|
||||
* If the unlock method is not supported, returns [UnlockWalletError.UnableToUnlock]
|
||||
* If the user cancels the unlock operation (ex. dismisses dialogs), returns [UnlockWalletError.UserCancelled].
|
||||
* If the scanned card does not match the wallet, returns [UnlockWalletError.ScannedCardWalletNotMatched].
|
||||
*/
|
||||
suspend fun unlock(userWalletId: UserWalletId, unlockMethod: UnlockMethod): Either<UnlockWalletError, Unit>
|
||||
|
||||
/**
|
||||
* Unlocks all user wallets using biometric authentication.
|
||||
* If all the wallets was are already unlocked, returns [UnlockWalletError.AlreadyUnlocked].
|
||||
* Success if at least one wallet was unlocked.
|
||||
* If the biometric method is not supported for some of user wallets, returns [UnlockWalletError.UnableToUnlock]
|
||||
*/
|
||||
suspend fun unlockAllWallets(): Either<UnlockWalletError, Unit>
|
||||
|
||||
/**
|
||||
* Locks all secured user wallets (wallets that are not locked with [LockMethod.NoLock]).
|
||||
* If all the wallets are already locked or unsecured, returns [LockWalletsError.NothingToLock].
|
||||
* Success if at least one wallet was locked.
|
||||
*/
|
||||
suspend fun lockAllWallets(): Either<LockWalletsError, Unit>
|
||||
|
||||
/**
|
||||
* Clears all persistent data related to user wallets.
|
||||
* This includes removing all user wallets, selected wallet, and any other related data.
|
||||
* User wallets will stay in the cache, but will be reloaded on next repository initialization.
|
||||
*/
|
||||
suspend fun clearPersistentData()
|
||||
|
||||
sealed class LockMethod {
|
||||
data object Biometric : LockMethod()
|
||||
class AccessCode(val accessCode: CharArray) : LockMethod()
|
||||
data object NoLock : LockMethod()
|
||||
}
|
||||
|
||||
enum class UnlockMethod {
|
||||
Biometric,
|
||||
AccessCode,
|
||||
Scan,
|
||||
}
|
||||
}
|
||||
|
||||
fun UserWalletsListRepository.requireUserWalletsSync(): List<UserWallet> {
|
||||
return userWallets.value ?: error("User wallets list is not loaded")
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.domain.wallets.models
|
||||
package com.tangem.domain.core.wallets.error
|
||||
|
||||
sealed interface DeleteWalletError {
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.domain.core.wallets.error
|
||||
|
||||
interface LockWalletsError {
|
||||
|
||||
data object NothingToLock : LockWalletsError
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.domain.wallets.models
|
||||
package com.tangem.domain.core.wallets.error
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.domain.core.wallets.error
|
||||
|
||||
sealed interface SelectWalletError {
|
||||
|
||||
data object UnableToSelectUserWallet : SelectWalletError
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.tangem.domain.core.wallets.error
|
||||
|
||||
sealed interface SetLockError {
|
||||
|
||||
data object UserWalletNotFound : SetLockError
|
||||
|
||||
data object UserWalletLocked : SetLockError
|
||||
|
||||
data class UnableToSetLock(val cause: Throwable) : SetLockError
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.domain.core.wallets.error
|
||||
|
||||
sealed interface UnlockWalletError {
|
||||
|
||||
data object AlreadyUnlocked : UnlockWalletError
|
||||
|
||||
data object UserWalletNotFound : UnlockWalletError
|
||||
|
||||
data object UnableToUnlock : UnlockWalletError
|
||||
|
||||
data object UserCancelled : UnlockWalletError
|
||||
|
||||
data object ScannedCardWalletNotMatched : UnlockWalletError
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ tasks.withType<Test>().configureEach {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.domain.core)
|
||||
api(projects.domain.visa.models)
|
||||
api(projects.core.utils)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ interface HotWalletPasswordRequester {
|
|||
|
||||
suspend fun wrongPassword()
|
||||
|
||||
suspend fun successfulAuthentication()
|
||||
|
||||
suspend fun requestPassword(hasBiometry: Boolean): Result
|
||||
|
||||
suspend fun dismiss()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import arrow.core.Either
|
|||
import arrow.core.raise.either
|
||||
import com.tangem.common.doOnFailure
|
||||
import com.tangem.domain.wallets.legacy.UserWalletsListManager
|
||||
import com.tangem.domain.wallets.models.DeleteWalletError
|
||||
import com.tangem.domain.core.wallets.error.DeleteWalletError
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import com.tangem.common.doOnFailure
|
|||
import com.tangem.common.doOnSuccess
|
||||
import com.tangem.domain.wallets.legacy.UserWalletsListError
|
||||
import com.tangem.domain.wallets.legacy.UserWalletsListManager
|
||||
import com.tangem.domain.wallets.models.SaveWalletError
|
||||
import com.tangem.domain.core.wallets.error.SaveWalletError
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import com.tangem.domain.card.ScanCardProcessor
|
|||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.redux.ReduxStateHolder
|
||||
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
|
||||
import com.tangem.domain.wallets.models.SaveWalletError
|
||||
import com.tangem.domain.core.wallets.error.SaveWalletError
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
|
||||
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsSyncUseCase
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ internal class DefaultHotAccessCodeRequestComponent @AssistedInject constructor(
|
|||
model.wrongAccessCode()
|
||||
}
|
||||
|
||||
override suspend fun successfulAuthentication() {
|
||||
// TODO handle successful authentication
|
||||
// TODO add delay
|
||||
}
|
||||
|
||||
override suspend fun requestPassword(hasBiometry: Boolean): HotWalletPasswordRequester.Result {
|
||||
model.show(hasBiometry)
|
||||
return model.waitResult()
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ class HotWalletPasswordRequesterProxy @Inject constructor() : HotWalletPasswordR
|
|||
call { wrongPassword() }
|
||||
}
|
||||
|
||||
override suspend fun successfulAuthentication() {
|
||||
call { successfulAuthentication() }
|
||||
}
|
||||
|
||||
override suspend fun requestPassword(hasBiometry: Boolean): HotWalletPasswordRequester.Result =
|
||||
call { requestPassword(hasBiometry) }
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ internal class AccessCodeModel @Inject constructor(
|
|||
unlockHotWallet = unlockHotWallet,
|
||||
auth = HotAuth.Password(accessCode.toCharArray()),
|
||||
)
|
||||
saveWalletUseCase(userWallet.copy(hotWalletId = updatedHotWalletId))
|
||||
saveWalletUseCase(userWallet.copy(hotWalletId = updatedHotWalletId), canOverride = true)
|
||||
params.callbacks.onAccessCodeConfirmed(params.userWalletId)
|
||||
}
|
||||
}.onFailure {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ tangemCardSdk = "develop-509"
|
|||
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
|
||||
tangemVico = "2.0.0-alpha.25-tangem12"
|
||||
#tangemVico = "0.0.1" # Keep it! - used for local builds ^
|
||||
tangemHotSdk = "develop-446"
|
||||
tangemHotSdk = "develop-448"
|
||||
#tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue