From b1c19eff9e4a95e2dc87004d89ddc79758e28051 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 17 Jul 2026 10:59:26 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/di/domain/AddressBookDomainModule.kt | 12 +- core/res/src/main/res/values/strings.xml | 5 + .../DefaultAddressBookRepository.kt | 32 ++++ .../DefaultAddressBookRepositoryTest.kt | 170 ++++++++++++++++-- .../addressbook/error/AddressBookSyncError.kt | 7 + .../addressbook/model/AddressBookBlob.kt | 26 ++- .../repository/AddressBookRepository.kt | 8 + .../usecase/IsAddressBookCompatibleUseCase.kt | 20 +++ .../model/AddressBookBlobVersionTest.kt | 47 +++++ features/address-book/impl/build.gradle.kts | 1 + .../common/AddressBookChildFactory.kt | 3 + .../editcontact/model/EditContactModel.kt | 19 +- .../list/DefaultAddressBookListComponent.kt | 8 + .../list/model/AddressBookListModel.kt | 59 +++--- .../SetAddressBookIncompatibleTransformer.kt | 13 ++ .../UpdateAddressBookListQueryTransformer.kt | 1 + .../list/ui/AddressBookIncompatibleScreen.kt | 95 ++++++++++ .../list/ui/state/AddressBookListUM.kt | 6 + .../list/model/AddressBookListModelTest.kt | 26 ++- .../destination/model/SendDestinationModel.kt | 10 +- .../model/SendDestinationModelTest.kt | 22 +++ 21 files changed, 538 insertions(+), 52 deletions(-) create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/IsAddressBookCompatibleUseCase.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/AddressBookBlobVersionTest.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/SetAddressBookIncompatibleTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookIncompatibleScreen.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt index 607b37e89b..98054d017f 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt @@ -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() diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 4438a040a1..d30cb8b166 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -441,6 +441,7 @@ Rename Required Reset + Retry Save Save changes Search @@ -741,6 +742,7 @@ Update Your operating system is out of date. Please update it to continue using the app. Update Your OS + Update app Please update the app to its latest version to ensure proper functionality. Update required Not enough funds @@ -2095,8 +2097,11 @@ Use crypto from your wallet to top up your payment account From your Tangem Wallet USDC on Polygon network + Account details + Available to deposit per day: Please try again or contact support if the issue persists Couldn\'t load banking details + Limit is resetting every day Visa Benefits 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 Please note diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt index 6bd7b64cfa..af732465ac 100644 --- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt @@ -95,6 +95,23 @@ internal class DefaultAddressBookRepository( decryptContacts(blob, userWallet).find { it.name.value == name } } + @OptIn(ExperimentalCoroutinesApi::class) + override fun isAddressBookCompatible(userWalletId: UserWalletId?): Flow { + 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 = 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 { + 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> { val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList().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 -> diff --git a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt index de73adfc33..5c1be8cf4b 100644 --- a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt +++ b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt @@ -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() + 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 = 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", diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt index 1e01d618cb..4944f212bc 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt @@ -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 } \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt index 89cbb8a375..a68ca9f36a 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressBookBlob.kt @@ -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() + } } } \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt index 4e2cb2935f..4a61ed592f 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt @@ -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 + suspend fun saveContact(contact: Contact): Either suspend fun deleteContact(id: ContactId): Either diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/IsAddressBookCompatibleUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/IsAddressBookCompatibleUseCase.kt new file mode 100644 index 0000000000..d56226fddf --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/IsAddressBookCompatibleUseCase.kt @@ -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 = + repository.isAddressBookCompatible(userWalletId) +} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/AddressBookBlobVersionTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/AddressBookBlobVersionTest.kt new file mode 100644 index 0000000000..e50e7a3b2f --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/AddressBookBlobVersionTest.kt @@ -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 + ) +} \ No newline at end of file diff --git a/features/address-book/impl/build.gradle.kts b/features/address-book/impl/build.gradle.kts index 1c9781a086..2068f070d0 100644 --- a/features/address-book/impl/build.gradle.kts +++ b/features/address-book/impl/build.gradle.kts @@ -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) diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt index ff434dd169..1e1066a991 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt @@ -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, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt index 7f76faa746..5c3b874720 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt @@ -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) diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt index 995ebd9b60..3a60d8a5e2 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt @@ -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, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt index 1f055fe4a5..d69c5cf37f 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt @@ -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) { diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/SetAddressBookIncompatibleTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/SetAddressBookIncompatibleTransformer.kt new file mode 100644 index 0000000000..3bbd537901 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/SetAddressBookIncompatibleTransformer.kt @@ -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 { + + override fun transform(prevState: AddressBookListUM): AddressBookListUM = AddressBookListUM.Incompatible +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt index 4b652b6119..7cc8fd2210 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt @@ -14,6 +14,7 @@ internal class UpdateAddressBookListQueryTransformer( ) is AddressBookListUM.Empty, AddressBookListUM.Loading, + AddressBookListUM.Incompatible, -> prevState } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookIncompatibleScreen.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookIncompatibleScreen.kt new file mode 100644 index 0000000000..7c03d6ab7e --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookIncompatibleScreen.kt @@ -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 = {}) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt index 8adb2c2cc5..dbbf3783c8 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt @@ -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 /** diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt index cb46cea181..f9917651eb 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt @@ -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 } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt index fb2eeeaf48..b502e8bbcb 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt @@ -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 = + isAddressBookCompatibleUseCase(userWalletId) + .flowOn(dispatchers.default) + .stateIn(modelScope, SharingStarted.Eagerly, initialValue = true) + val addressSelectorNavigation = SlotNavigation() val addressQuery: StateFlow = uiState .map { (it as? DestinationUM.Content)?.addressTextField?.value.orEmpty() } @@ -118,7 +125,8 @@ internal class SendDestinationModel @Inject constructor( */ val showAddContact: StateFlow = 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 diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt index 66a516e29a..536a58aae2 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt @@ -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>(), 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, )