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,
)