Updated on 2026-08-14
This commit is contained in:
parent
98d0d9c75e
commit
b1c19eff9e
21 changed files with 538 additions and 52 deletions
|
|
@ -6,11 +6,7 @@ import com.tangem.domain.addressbook.interactor.SaveContactInteractor
|
|||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.addressbook.time.DefaultIsoTimestampProvider
|
||||
import com.tangem.domain.addressbook.time.IsoTimestampProvider
|
||||
import com.tangem.domain.addressbook.usecase.CheckAddressDuplicateUseCase
|
||||
import com.tangem.domain.addressbook.usecase.DeleteContactUseCase
|
||||
import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase
|
||||
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
|
||||
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
|
||||
import com.tangem.domain.addressbook.usecase.*
|
||||
import com.tangem.domain.addressbook.validation.ContactNameValidator
|
||||
import com.tangem.domain.addressbook.verification.ContactSignatureVerifier
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
|
|
@ -120,6 +116,12 @@ object AddressBookDomainModule {
|
|||
return SyncAddressBooksUseCase(repository = repository)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideIsAddressBookCompatibleUseCase(repository: AddressBookRepository): IsAddressBookCompatibleUseCase {
|
||||
return IsAddressBookCompatibleUseCase(repository = repository)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAddressBookCipher(): AddressBookCipher = AddressBookCipher()
|
||||
|
|
|
|||
|
|
@ -441,6 +441,7 @@
|
|||
<string name="common_rename">Rename</string>
|
||||
<string name="common_required">Required</string>
|
||||
<string name="common_reset">Reset</string>
|
||||
<string name="common_retry">Retry</string>
|
||||
<string name="common_save">Save</string>
|
||||
<string name="common_save_changes">Save changes</string>
|
||||
<string name="common_search">Search</string>
|
||||
|
|
@ -741,6 +742,7 @@
|
|||
<string name="force_update_button">Update</string>
|
||||
<string name="force_update_os_description">Your operating system is out of date. Please update it to continue using the app.</string>
|
||||
<string name="force_update_os_title">Update Your OS</string>
|
||||
<string name="force_update_required_action">Update app</string>
|
||||
<string name="force_update_warning_message">Please update the app to its latest version to ensure proper functionality.</string>
|
||||
<string name="force_update_warning_title">Update required</string>
|
||||
<string name="gasless_not_enough_funds_to_cover_token_fee">Not enough funds</string>
|
||||
|
|
@ -2095,8 +2097,11 @@
|
|||
<string name="tangempay_topup_swap_body">Use crypto from your wallet to top up your payment account</string>
|
||||
<string name="tangempay_topup_swap_title">From your Tangem Wallet</string>
|
||||
<string name="tangempay_usdc_on_polygon_network">USDC on Polygon network</string>
|
||||
<string name="tangempay_va_account_details">Account details</string>
|
||||
<string name="tangempay_va_available_to_deposit_day">Available to deposit per day:</string>
|
||||
<string name="tangempay_va_banking_details_error_description">Please try again or contact support if the issue persists</string>
|
||||
<string name="tangempay_va_banking_details_error_title">Couldn\'t load banking details</string>
|
||||
<string name="tangempay_va_limit_resetting_everyday">Limit is resetting every day</string>
|
||||
<string name="tangempay_visa_benefits">Visa Benefits</string>
|
||||
<string name="tangempay_withdrawal_note_description">Funds from refunded purchases won’t be returned your on-chain Polygon balance or be available for withdrawal, but will stay on your card balance for purchases</string>
|
||||
<string name="tangempay_withdrawal_note_title">Please note</string>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,23 @@ internal class DefaultAddressBookRepository(
|
|||
decryptContacts(blob, userWallet).find { it.name.value == name }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun isAddressBookCompatible(userWalletId: UserWalletId?): Flow<Boolean> {
|
||||
val source = if (userWalletId != null) {
|
||||
blobStore.getBlob(userWalletId).map { blob -> blob?.isVersionCompatible != false }
|
||||
} else {
|
||||
userWalletsListRepository.userWallets
|
||||
.filterNotNull()
|
||||
.flatMapLatest { wallets ->
|
||||
val ids = wallets.mapTo(mutableSetOf()) { it.walletId }
|
||||
// getBlobs returns only wallets that already have a stored book; wallets without one are
|
||||
// absent here and count as compatible (a fresh book uses the supported version).
|
||||
blobStore.getBlobs(ids).map { blobs -> blobs.all { it.isVersionCompatible } }
|
||||
}
|
||||
}
|
||||
return source.distinctUntilChanged().flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
override suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit> =
|
||||
withContext(dispatchers.default) {
|
||||
writeMutex.withLock {
|
||||
|
|
@ -112,6 +129,7 @@ internal class DefaultAddressBookRepository(
|
|||
writeMutex.withLock {
|
||||
userWalletsListRepository.userWalletsSync().forEach { userWallet ->
|
||||
val blob = blobStore.getBlobSync(userWallet.walletId) ?: return@forEach
|
||||
if (!blob.isVersionCompatible) return@forEach
|
||||
val addressBook = cipher.decrypt(blob, userWallet).getOrNull() ?: return@forEach
|
||||
if (addressBook.contacts.none { it.id == id }) return@forEach
|
||||
|
||||
|
|
@ -171,6 +189,13 @@ internal class DefaultAddressBookRepository(
|
|||
}
|
||||
|
||||
private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List<Contact> {
|
||||
if (!blob.isVersionCompatible) {
|
||||
logger.e(
|
||||
"Skipping address book for wallet ${blob.walletId}: version ${blob.version} is newer than " +
|
||||
"supported ${AddressBookBlob.CURRENT_VERSION}",
|
||||
)
|
||||
return emptyList()
|
||||
}
|
||||
return cipher.decrypt(blob, userWallet).fold(
|
||||
ifLeft = { error ->
|
||||
// The cipher already logged the low-level cause; this ties the failure to the read path so QA
|
||||
|
|
@ -195,6 +220,13 @@ internal class DefaultAddressBookRepository(
|
|||
userWallet: UserWallet,
|
||||
): Either<AddressBookSyncError, List<Contact>> {
|
||||
val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList<Contact>().right()
|
||||
if (!blob.isVersionCompatible) {
|
||||
logger.e(
|
||||
"Refusing to overwrite address book for wallet $userWalletId: stored version ${blob.version} is " +
|
||||
"newer than supported ${AddressBookBlob.CURRENT_VERSION} — a write would downgrade it",
|
||||
)
|
||||
return AddressBookSyncError.VersionMismatch.left()
|
||||
}
|
||||
return cipher.decrypt(blob, userWallet)
|
||||
.map { it.contacts }
|
||||
.mapLeft { error ->
|
||||
|
|
|
|||
|
|
@ -14,22 +14,13 @@ import com.tangem.datasource.api.common.response.ApiResponseError
|
|||
import com.tangem.domain.addressbook.crypto.AddressBookCipher
|
||||
import com.tangem.domain.addressbook.error.AddressBookCryptoError
|
||||
import com.tangem.domain.addressbook.error.AddressBookSyncError
|
||||
import com.tangem.domain.addressbook.model.AddressBook
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.model.*
|
||||
import com.tangem.domain.addressbook.time.IsoTimestampProvider
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
|
@ -402,6 +393,155 @@ internal class DefaultAddressBookRepositoryTest {
|
|||
assertThat(result).isEqualTo(bob)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored version newer than supported WHEN getContacts THEN emits empty without decrypting`() = runTest {
|
||||
// Arrange
|
||||
val blob = createBlob(version = "2.0")
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
|
||||
|
||||
// Act
|
||||
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
verify(exactly = 0) { cipher.decrypt(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored version older than supported WHEN getContacts THEN decrypts normally`() = runTest {
|
||||
// Arrange
|
||||
val contact = createContact(id = "c1", name = "Alice")
|
||||
val blob = createBlob(version = "0.9")
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
|
||||
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
|
||||
|
||||
// Act
|
||||
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(contact)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored version newer than supported WHEN saveContact THEN VersionMismatch and pushes nothing`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val storedBlob = createBlob(version = "2.0")
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
|
||||
|
||||
// Act
|
||||
val result = repository.saveContact(createContact(id = "c2", name = "Bob"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(AddressBookSyncError.VersionMismatch.left())
|
||||
verify(exactly = 0) { cipher.decrypt(any(), any()) }
|
||||
coVerify(exactly = 0) { addressBookApi.updateAddressBook(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored version older than supported WHEN saveContact THEN pushes upgraded to current version`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val storedBlob = createBlob(version = "0.9")
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
|
||||
every { cipher.decrypt(storedBlob, userWallet) } returns AddressBook(emptyList()).right()
|
||||
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
|
||||
coEvery { blobStore.storeBlob(any()) } returns Unit
|
||||
val bodySlot = slot<com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest>()
|
||||
coEvery {
|
||||
addressBookApi.updateAddressBook(WALLET_A, any(), capture(bodySlot))
|
||||
} returns successPutResponse()
|
||||
|
||||
// Act
|
||||
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(Unit.right())
|
||||
assertThat(bodySlot.captured.version).isEqualTo(AddressBookBlob.CURRENT_VERSION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact wallet version newer than supported WHEN deleteContact THEN book left untouched`() = runTest {
|
||||
// Arrange
|
||||
val storedBlob = createBlob(version = "2.0")
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
|
||||
|
||||
// Act
|
||||
val result = repository.deleteContact(ContactId("c2"))
|
||||
|
||||
// Assert — the newer book is skipped, so nothing is decrypted, pushed or stored.
|
||||
assertThat(result).isEqualTo(Unit.right())
|
||||
verify(exactly = 0) { cipher.decrypt(any(), any()) }
|
||||
coVerify(exactly = 0) { addressBookApi.updateAddressBook(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no book WHEN isAddressBookCompatible for wallet THEN true`() = runTest {
|
||||
// Arrange
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(null)
|
||||
|
||||
// Act
|
||||
val result = repository.isAddressBookCompatible(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN version at or below supported WHEN isAddressBookCompatible for wallet THEN true`() = runTest {
|
||||
// Arrange
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(createBlob(version = "0.9"))
|
||||
|
||||
// Act
|
||||
val result = repository.isAddressBookCompatible(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN version newer than supported WHEN isAddressBookCompatible for wallet THEN false`() = runTest {
|
||||
// Arrange
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(createBlob(version = "2.0"))
|
||||
|
||||
// Act
|
||||
val result = repository.isAddressBookCompatible(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN one wallet book newer WHEN isAddressBookCompatible for all THEN false`() = runTest {
|
||||
// Arrange
|
||||
val walletB: UserWallet = mockk { every { walletId } returns UserWalletId(WALLET_B) }
|
||||
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet, walletB))
|
||||
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A), UserWalletId(WALLET_B))) } returns
|
||||
flowOf(listOf(createBlob(walletId = WALLET_A), createBlob(walletId = WALLET_B, version = "2.0")))
|
||||
|
||||
// Act
|
||||
val result = repository.isAddressBookCompatible().first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN all wallet books supported WHEN isAddressBookCompatible for all THEN true`() = runTest {
|
||||
// Arrange
|
||||
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
|
||||
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns
|
||||
flowOf(listOf(createBlob(version = "0.9")))
|
||||
|
||||
// Act
|
||||
val result = repository.isAddressBookCompatible().first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
private fun successPutResponse(etag: String = ETAG_NEW): ApiResponse<UpdateAddressBookResponse> =
|
||||
ApiResponse.Success(
|
||||
data = UpdateAddressBookResponse(walletId = WALLET_A, etag = etag, updatedAt = TIMESTAMP),
|
||||
|
|
@ -438,8 +578,12 @@ internal class DefaultAddressBookRepositoryTest {
|
|||
addresses = emptyList(),
|
||||
)
|
||||
|
||||
private fun createBlob(): AddressBookBlob = AddressBookBlob(
|
||||
walletId = WALLET_A,
|
||||
private fun createBlob(
|
||||
walletId: String = WALLET_A,
|
||||
version: String = AddressBookBlob.CURRENT_VERSION,
|
||||
): AddressBookBlob = AddressBookBlob(
|
||||
version = version,
|
||||
walletId = walletId,
|
||||
updatedAt = TIMESTAMP,
|
||||
nonce = "00112233445566778899aabb",
|
||||
ciphertext = "deadbeef",
|
||||
|
|
|
|||
|
|
@ -31,4 +31,11 @@ sealed interface AddressBookSyncError {
|
|||
|
||||
/** Any other unexpected failure (encryption, missing data, unmapped HTTP code). */
|
||||
data object Unknown : AddressBookSyncError
|
||||
|
||||
/**
|
||||
* The stored book uses a contract version newer than this build supports
|
||||
* ([com.tangem.domain.addressbook.model.AddressBookBlob.isVersionCompatible]). Determined locally (no
|
||||
* network), the write is refused so a book the app cannot fully understand is not downgraded/overwritten.
|
||||
*/
|
||||
data object VersionMismatch : AddressBookSyncError
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.domain.addressbook.model
|
||||
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob.Companion.CURRENT_VERSION
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
|
|
@ -26,7 +27,7 @@ import kotlinx.serialization.Serializable
|
|||
@Serializable
|
||||
data class AddressBookBlob(
|
||||
@SerialName("version")
|
||||
val version: String = CURRENT_VERSION, // TODO Will come from BE in [REDACTED_TASK_KEY]
|
||||
val version: String = CURRENT_VERSION,
|
||||
@SerialName("walletId")
|
||||
val walletId: String,
|
||||
@SerialName("updatedAt")
|
||||
|
|
@ -39,7 +40,30 @@ data class AddressBookBlob(
|
|||
val authTag: String,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Whether this build can safely read and write the blob — i.e. its [version] is not newer than the
|
||||
* contract this app supports ([CURRENT_VERSION]). See [isVersionCompatible].
|
||||
*/
|
||||
val isVersionCompatible: Boolean get() = isVersionCompatible(version)
|
||||
|
||||
companion object {
|
||||
const val CURRENT_VERSION = "1.0"
|
||||
|
||||
/**
|
||||
* A blob is compatible when its contract version is **not higher** than [CURRENT_VERSION]. The version
|
||||
* is treated as a plain number (major/minor are not distinguished — any higher value is incompatible):
|
||||
* - lower (`0.9`) — the app understands a newer contract; reads work and a write upgrades the backend
|
||||
* copy to [CURRENT_VERSION].
|
||||
* - equal — same contract.
|
||||
* - higher (`1.1`, `2.0`) — the backend contract is newer than this build understands, so the book must
|
||||
* be treated as read-only-opaque (not read, not written).
|
||||
*
|
||||
* A version that cannot be parsed as a number is treated as incompatible — safer to refuse a blob we
|
||||
* cannot reason about.
|
||||
*/
|
||||
fun isVersionCompatible(version: String): Boolean {
|
||||
val parsed = version.trim().toDoubleOrNull() ?: return false
|
||||
return parsed <= CURRENT_VERSION.toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,14 @@ interface AddressBookRepository {
|
|||
|
||||
suspend fun getContact(userWalletId: UserWalletId, name: String): Contact?
|
||||
|
||||
/**
|
||||
* Whether the stored address book(s) can be used by this build — i.e. their contract version is not newer
|
||||
* than the one this app supports (see [com.tangem.domain.addressbook.model.AddressBookBlob.isVersionCompatible]).
|
||||
* @param userWalletId a specific wallet, or `null` to check every wallet — `null` is compatible only when
|
||||
* **all** currently stored books are compatible.
|
||||
*/
|
||||
fun isAddressBookCompatible(userWalletId: UserWalletId? = null): Flow<Boolean>
|
||||
|
||||
suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit>
|
||||
|
||||
suspend fun deleteContact(id: ContactId): Either<AddressBookSyncError, Unit>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Emits whether the stored address book(s) can be used by this build — `false` when the backend contract
|
||||
* version is newer than the one this app supports, so consumers can degrade (hide the book, block editing).
|
||||
*
|
||||
* @param userWalletId a specific wallet, or `null` to check every wallet (compatible only when all stored
|
||||
* books are compatible). See [AddressBookRepository.isAddressBookCompatible].
|
||||
*/
|
||||
class IsAddressBookCompatibleUseCase(
|
||||
private val repository: AddressBookRepository,
|
||||
) {
|
||||
|
||||
operator fun invoke(userWalletId: UserWalletId? = null): Flow<Boolean> =
|
||||
repository.isAddressBookCompatible(userWalletId)
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.tangem.domain.addressbook.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class AddressBookBlobVersionTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("provideVersions")
|
||||
fun `isVersionCompatible against CURRENT_VERSION`(model: VersionModel) {
|
||||
// CURRENT_VERSION is 1.0 — the cases below are written relative to it.
|
||||
assertThat(AddressBookBlob.isVersionCompatible(model.version)).isEqualTo(model.expectedCompatible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blob property mirrors the version function`() {
|
||||
val newer = AddressBookBlob(
|
||||
version = "2.0",
|
||||
walletId = "w",
|
||||
updatedAt = "t",
|
||||
nonce = "n",
|
||||
ciphertext = "c",
|
||||
authTag = "a",
|
||||
)
|
||||
|
||||
assertThat(newer.isVersionCompatible).isFalse()
|
||||
}
|
||||
|
||||
internal data class VersionModel(val version: String, val expectedCompatible: Boolean)
|
||||
|
||||
private fun provideVersions() = listOf(
|
||||
VersionModel(version = "1.0", expectedCompatible = true), // equal
|
||||
VersionModel(version = "0.9", expectedCompatible = true), // lower
|
||||
VersionModel(version = "0.5", expectedCompatible = true), // lower
|
||||
VersionModel(version = "1", expectedCompatible = true), // 1 == 1.0
|
||||
VersionModel(version = "1.1", expectedCompatible = false), // higher
|
||||
VersionModel(version = "2.0", expectedCompatible = false), // higher
|
||||
VersionModel(version = "1.10", expectedCompatible = false), // 1.10 == 1.1 as a number, higher than 1.0
|
||||
VersionModel(version = "", expectedCompatible = false), // not a number
|
||||
VersionModel(version = "1.0.0", expectedCompatible = false), // not a number
|
||||
VersionModel(version = "abc", expectedCompatible = false) // not a number
|
||||
)
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ dependencies {
|
|||
api(projects.core.decompose)
|
||||
api(projects.core.ui)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.navigation)
|
||||
|
||||
/** Compose */
|
||||
api(deps.compose.foundation)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.features.addressbook.common
|
||||
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.navigation.url.AppStoreOpener
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.AddressSelectorComponent
|
||||
|
|
@ -21,6 +22,7 @@ import javax.inject.Inject
|
|||
internal class AddressBookChildFactory @Inject constructor(
|
||||
private val addressSelectorFactory: AddressSelectorComponent.Factory,
|
||||
private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
|
||||
private val appStoreOpener: AppStoreOpener,
|
||||
) {
|
||||
|
||||
fun createChild(
|
||||
|
|
@ -36,6 +38,7 @@ internal class AddressBookChildFactory @Inject constructor(
|
|||
onAddContactClick = clickIntents::onAddContactClick,
|
||||
),
|
||||
addressSelectorFactory = addressSelectorFactory,
|
||||
appStoreOpener = appStoreOpener,
|
||||
)
|
||||
is AddressBookRoute.EditContact -> DefaultEditContactComponent(
|
||||
appComponentContext = context,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.tangem.core.ui.extensions.resourceReference
|
|||
import com.tangem.core.ui.message.DialogMessage
|
||||
import com.tangem.core.ui.message.EventMessageAction
|
||||
import com.tangem.core.ui.message.SnackbarMessage
|
||||
import com.tangem.domain.addressbook.error.AddressBookSyncError
|
||||
import com.tangem.domain.addressbook.error.ContactNameValidationError
|
||||
import com.tangem.domain.addressbook.error.SaveContactError
|
||||
import com.tangem.domain.addressbook.interactor.SaveContactInteractor
|
||||
|
|
@ -451,10 +452,12 @@ internal class EditContactModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun handleSaveError(error: SaveContactError) {
|
||||
when (error) {
|
||||
is SaveContactError.Name -> stateController.update(
|
||||
when {
|
||||
error is SaveContactError.Name -> stateController.update(
|
||||
UpdateNameErrorTransformer(ContactNameErrorConverter().convert(error.error)),
|
||||
)
|
||||
error is SaveContactError.Backend && error.error is AddressBookSyncError.VersionMismatch ->
|
||||
showUpdateAppDialog()
|
||||
else -> messageSender.send(
|
||||
DialogMessage(
|
||||
title = resourceReference(R.string.common_something_went_wrong),
|
||||
|
|
@ -469,6 +472,14 @@ internal class EditContactModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
}
|
||||
private fun showUpdateAppDialog() {
|
||||
messageSender.send(
|
||||
DialogMessage(
|
||||
title = resourceReference(R.string.force_update_warning_title),
|
||||
message = resourceReference(R.string.force_update_warning_message),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun deleteContact(fromLastAddressRemoval: Boolean) {
|
||||
val contactId = params.contactId ?: return
|
||||
|
|
@ -479,7 +490,9 @@ internal class EditContactModel @Inject constructor(
|
|||
}
|
||||
modelScope.launch {
|
||||
deleteContactUseCase(contactId).fold(
|
||||
ifLeft = { showDeleteError() },
|
||||
ifLeft = { error ->
|
||||
if (error is AddressBookSyncError.VersionMismatch) showUpdateAppDialog() else showDeleteError()
|
||||
},
|
||||
ifRight = {
|
||||
if (walletId != null) {
|
||||
analyticsSender.sendContactDeleted(walletId = walletId, contactId = contactId.value)
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ import com.arkivanov.decompose.router.slot.dismiss
|
|||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.navigation.url.AppStoreOpener
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.addressbook.AddressSelectorComponent
|
||||
import com.tangem.features.addressbook.list.model.AddressBookListModel
|
||||
import com.tangem.features.addressbook.list.ui.AddressBookEmptyScreen
|
||||
import com.tangem.features.addressbook.list.ui.AddressBookIncompatibleScreen
|
||||
import com.tangem.features.addressbook.list.ui.AddressBookListScreen
|
||||
import com.tangem.features.addressbook.list.ui.AddressBookListShimmer
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
|
|
@ -25,6 +27,7 @@ internal class DefaultAddressBookListComponent(
|
|||
appComponentContext: AppComponentContext,
|
||||
params: Params,
|
||||
addressSelectorFactory: AddressSelectorComponent.Factory,
|
||||
private val appStoreOpener: AppStoreOpener,
|
||||
) : ComposableContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: AddressBookListModel = getOrCreateModel(params)
|
||||
|
|
@ -55,6 +58,11 @@ internal class DefaultAddressBookListComponent(
|
|||
onBackClick = router::pop,
|
||||
modifier = modifier.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
is AddressBookListUM.Incompatible -> AddressBookIncompatibleScreen(
|
||||
onBackClick = router::pop,
|
||||
modifier = modifier.background(TangemTheme.colors3.bg.primary),
|
||||
onUpdateClick = appStoreOpener::openStorePage,
|
||||
)
|
||||
is AddressBookListUM.Empty -> AddressBookEmptyScreen(
|
||||
onAddContactClick = addressBookListUM.onAddClick,
|
||||
onBackClick = router::pop,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.tangem.core.decompose.model.ParamsContainer
|
|||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.usecase.IsAddressBookCompatibleUseCase
|
||||
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
|
@ -20,6 +21,7 @@ import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListSc
|
|||
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
|
||||
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
|
||||
import com.tangem.features.addressbook.list.state.AddressBookListStateController
|
||||
import com.tangem.features.addressbook.list.state.transformers.SetAddressBookIncompatibleTransformer
|
||||
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListContentTransformer
|
||||
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListQueryTransformer
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
|
|
@ -48,6 +50,7 @@ internal class AddressBookListModel @Inject constructor(
|
|||
private val contactSelectionTrigger: ContactSelectionTrigger,
|
||||
private val analyticsSender: AddressBookAnalyticsSender,
|
||||
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
|
||||
private val isAddressBookCompatibleUseCase: IsAddressBookCompatibleUseCase,
|
||||
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
|
||||
private val getWalletsUseCase: GetWalletsUseCase,
|
||||
) : Model() {
|
||||
|
|
@ -76,31 +79,39 @@ internal class AddressBookListModel @Inject constructor(
|
|||
}
|
||||
|
||||
private suspend fun observeContacts() {
|
||||
val matchedContacts = searchQuery.flatMapLatest { query ->
|
||||
if (query.isBlank()) {
|
||||
allContacts
|
||||
} else {
|
||||
getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null)
|
||||
isAddressBookCompatibleUseCase()
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { isCompatible ->
|
||||
if (isCompatible) {
|
||||
val matchedContacts = searchQuery.flatMapLatest { query ->
|
||||
if (query.isBlank()) {
|
||||
allContacts
|
||||
} else {
|
||||
getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null)
|
||||
}
|
||||
}
|
||||
combine(
|
||||
allContacts,
|
||||
matchedContacts,
|
||||
searchQuery,
|
||||
selectedWalletId,
|
||||
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
|
||||
) { all, matched, query, selected, wallets ->
|
||||
ListInputs(
|
||||
allContacts = all,
|
||||
matchedContacts = matched,
|
||||
query = query,
|
||||
selectedWalletId = selected,
|
||||
wallets = wallets,
|
||||
)
|
||||
}
|
||||
.onEach(::updateState)
|
||||
.flowOn(dispatchers.default)
|
||||
.collect()
|
||||
} else {
|
||||
stateController.update(SetAddressBookIncompatibleTransformer())
|
||||
}
|
||||
}
|
||||
}
|
||||
combine(
|
||||
allContacts,
|
||||
matchedContacts,
|
||||
searchQuery,
|
||||
selectedWalletId,
|
||||
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
|
||||
) { all, matched, query, selected, wallets ->
|
||||
ListInputs(
|
||||
allContacts = all,
|
||||
matchedContacts = matched,
|
||||
query = query,
|
||||
selectedWalletId = selected,
|
||||
wallets = wallets,
|
||||
)
|
||||
}
|
||||
.onEach(::updateState)
|
||||
.flowOn(dispatchers.default)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fun deliverSelection(contact: SelectedContact) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.features.addressbook.list.state.transformers
|
||||
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
/**
|
||||
* Replaces the whole list state with [AddressBookListUM.Incompatible] — used when a stored book uses a
|
||||
* contract version newer than this build supports.
|
||||
*/
|
||||
internal class SetAddressBookIncompatibleTransformer : Transformer<AddressBookListUM> {
|
||||
|
||||
override fun transform(prevState: AddressBookListUM): AddressBookListUM = AddressBookListUM.Incompatible
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ internal class UpdateAddressBookListQueryTransformer(
|
|||
)
|
||||
is AddressBookListUM.Empty,
|
||||
AddressBookListUM.Loading,
|
||||
AddressBookListUM.Incompatible,
|
||||
-> prevState
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package com.tangem.features.addressbook.list.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_info_24
|
||||
|
||||
/**
|
||||
* Shown when a stored address book uses a contract version newer than this build supports: the book can be
|
||||
* neither read nor edited, so the user is asked to update the app. No contacts, no add button.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AddressBookIncompatibleScreen(
|
||||
onUpdateClick: () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.windowInsetsPadding(WindowInsets.navigationBars),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
TangemTopNavigation(
|
||||
title = resourceReference(R.string.address_book_title),
|
||||
contentAlign = TangemTopNavigation.ContentAlign.Center,
|
||||
blurBackground = false,
|
||||
onBack = onBackClick,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.background(color = TangemTheme.colors3.bg.status.infoSubtle, shape = CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.ic_info_24,
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.status.info,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 32.dp),
|
||||
text = stringResourceSafe(R.string.force_update_warning_title),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
text = stringResourceSafe(R.string.force_update_warning_message),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.subheading.medium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
TangemButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
size = TangemButton.Size.X12,
|
||||
onClick = onUpdateClick,
|
||||
text = resourceReference(R.string.force_update_required_action),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
private fun Preview_AddressBookIncompatibleScreen() {
|
||||
TangemThemePreviewRedesign {
|
||||
AddressBookIncompatibleScreen(onBackClick = {}, onUpdateClick = {})
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,12 @@ internal sealed interface AddressBookListUM {
|
|||
/** Initial state while the address books are being (re-)synced on open — rendered as shimmer placeholders. */
|
||||
data object Loading : AddressBookListUM
|
||||
|
||||
/**
|
||||
* A stored book uses a contract version newer than this build supports, so it can be neither read nor
|
||||
* safely edited. The screen shows an "update the app" stub instead of the list — no contacts, no add button.
|
||||
*/
|
||||
data object Incompatible : AddressBookListUM
|
||||
|
||||
data class Empty(val onAddClick: () -> Unit) : AddressBookListUM
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.tangem.core.decompose.model.MutableParamsContainer
|
|||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
|
||||
import com.tangem.domain.addressbook.model.*
|
||||
import com.tangem.domain.addressbook.usecase.IsAddressBookCompatibleUseCase
|
||||
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.domain.models.network.Network
|
||||
|
|
@ -20,11 +21,7 @@ import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
|||
import com.tangem.features.addressbook.list.ui.state.ContentMode
|
||||
import com.tangem.features.addressbook.route.AddressBookRoute
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
|
@ -48,6 +45,7 @@ internal class AddressBookListModelTest {
|
|||
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor = mockk()
|
||||
private val getWalletsUseCase: GetWalletsUseCase = mockk()
|
||||
private val syncAddressBooksUseCase: SyncAddressBooksUseCase = mockk(relaxed = true)
|
||||
private val isAddressBookCompatibleUseCase: IsAddressBookCompatibleUseCase = mockk()
|
||||
|
||||
private var model: AddressBookListModel? = null
|
||||
|
||||
|
|
@ -59,9 +57,11 @@ internal class AddressBookListModelTest {
|
|||
analyticsSender,
|
||||
contactSelectionTrigger,
|
||||
syncAddressBooksUseCase,
|
||||
isAddressBookCompatibleUseCase,
|
||||
)
|
||||
every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns
|
||||
flowOf(linkedMapOf())
|
||||
every { isAddressBookCompatibleUseCase() } returns flowOf(true)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
|
@ -121,6 +121,21 @@ internal class AddressBookListModelTest {
|
|||
assertThat(state.contacts.map { it.name }).containsExactly("Alice", "Bob")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a book newer than supported WHEN created THEN incompatible state shown`() = runTest {
|
||||
// Arrange
|
||||
every { isAddressBookCompatibleUseCase() } returns flowOf(false)
|
||||
every { getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null) } returns
|
||||
flowOf(listOf(contact(id = "1", name = "Alice")))
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — the "update the app" stub replaces the list even though contacts are cached.
|
||||
assertThat(model.state.value).isEqualTo(AddressBookListUM.Incompatible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN default mode AND no contacts WHEN created THEN empty state`() = runTest {
|
||||
// Arrange
|
||||
|
|
@ -243,6 +258,7 @@ internal class AddressBookListModelTest {
|
|||
contactSelectionTrigger = contactSelectionTrigger,
|
||||
analyticsSender = analyticsSender,
|
||||
syncAddressBooksUseCase = syncAddressBooksUseCase,
|
||||
isAddressBookCompatibleUseCase = isAddressBookCompatibleUseCase,
|
||||
getVerifiedContactsInteractor = getVerifiedContactsInteractor,
|
||||
getWalletsUseCase = getWalletsUseCase,
|
||||
).also { model = it }
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAdd
|
|||
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
|
||||
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.usecase.IsAddressBookCompatibleUseCase
|
||||
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
|
||||
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
|
|
@ -88,6 +89,7 @@ internal class SendDestinationModel @Inject constructor(
|
|||
private val addressBookSendAnalytics: AddressBookSendAnalytics,
|
||||
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
|
||||
private val addressBookFeatureToggles: AddressBookFeatureToggles,
|
||||
isAddressBookCompatibleUseCase: IsAddressBookCompatibleUseCase,
|
||||
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
|
||||
contactSelectionListener: ContactSelectionListener,
|
||||
) : Model(), SendDestinationClickIntents {
|
||||
|
|
@ -105,6 +107,11 @@ internal class SendDestinationModel @Inject constructor(
|
|||
.flowOn(dispatchers.default)
|
||||
.stateIn(modelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private val isAddressBookCompatible: StateFlow<Boolean> =
|
||||
isAddressBookCompatibleUseCase(userWalletId)
|
||||
.flowOn(dispatchers.default)
|
||||
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = true)
|
||||
|
||||
val addressSelectorNavigation = SlotNavigation<MatchedContact>()
|
||||
val addressQuery: StateFlow<String> = uiState
|
||||
.map { (it as? DestinationUM.Content)?.addressTextField?.value.orEmpty() }
|
||||
|
|
@ -118,7 +125,8 @@ internal class SendDestinationModel @Inject constructor(
|
|||
*/
|
||||
val showAddContact: StateFlow<Boolean> =
|
||||
if ((params as? DestinationBlockParams)?.isAddContactAvailable == true) {
|
||||
combine(uiState, contacts) { state, contactList ->
|
||||
combine(uiState, contacts, isAddressBookCompatible) { state, contactList, isCompatible ->
|
||||
if (!isCompatible) return@combine false
|
||||
val address = (state as? DestinationUM.Content)?.addressTextField ?: return@combine false
|
||||
if (address.contactName != null) return@combine false // sent via a contact
|
||||
val networkId = cryptoCurrency.network.rawId
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAdd
|
|||
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
|
||||
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
|
||||
import com.tangem.domain.addressbook.model.*
|
||||
import com.tangem.domain.addressbook.usecase.IsAddressBookCompatibleUseCase
|
||||
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
|
||||
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
|
|
@ -94,6 +95,7 @@ internal class SendDestinationModelTest {
|
|||
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true)
|
||||
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor = mockk(relaxed = true)
|
||||
private val syncAddressBooksUseCase: SyncAddressBooksUseCase = mockk(relaxed = true)
|
||||
private val isAddressBookCompatibleUseCase: IsAddressBookCompatibleUseCase = mockk(relaxed = true)
|
||||
private val addressBookFeatureToggles: AddressBookFeatureToggles = mockk(relaxed = true)
|
||||
private val contactSelectionListener: ContactSelectionListener = mockk(relaxed = true)
|
||||
private val addressBookSendAnalytics: AddressBookSendAnalytics = mockk(relaxed = true)
|
||||
|
|
@ -490,6 +492,25 @@ internal class SendDestinationModelTest {
|
|||
assertThat(sut.showAddContact.value).isEqualTo(model.expectedShown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN book newer than supported WHEN fresh address entered THEN add-contact hidden`() = runTest {
|
||||
// Arrange
|
||||
coEvery {
|
||||
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
|
||||
} returns AddressValidation.Success.Valid.right()
|
||||
every { getVerifiedContactsInteractor.getVerifiedContacts(any(), any()) } returns flowOf(emptyList())
|
||||
every { isAddressBookCompatibleUseCase(testUserWalletId) } returns flowOf(false)
|
||||
val sut = buildBlockModel(isAddContactAvailable = true)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
sut.onRecipientAddressValueChange("0xFresh", EnterAddressSource.InputField)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — a newer book can't be written, so saving the recipient is never offered.
|
||||
assertThat(sut.showAddContact.value).isFalse()
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
// not available -> never shown, even for a fresh valid address
|
||||
AddContactModel(
|
||||
|
|
@ -667,6 +688,7 @@ internal class SendDestinationModelTest {
|
|||
addressBookSendAnalytics = addressBookSendAnalytics,
|
||||
syncAddressBooksUseCase = syncAddressBooksUseCase,
|
||||
addressBookFeatureToggles = addressBookFeatureToggles,
|
||||
isAddressBookCompatibleUseCase = isAddressBookCompatibleUseCase,
|
||||
getVerifiedContactsInteractor = getVerifiedContactsInteractor,
|
||||
contactSelectionListener = contactSelectionListener,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue