Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-17 10:59:26 +02:00
parent 98d0d9c75e
commit b1c19eff9e
21 changed files with 538 additions and 52 deletions

View file

@ -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()

View file

@ -441,6 +441,7 @@
<string name="common_rename">Rename</string>
<string name="common_required">Required</string>
<string name="common_reset">Reset</string>
<string name="common_retry">Retry</string>
<string name="common_save">Save</string>
<string name="common_save_changes">Save changes</string>
<string name="common_search">Search</string>
@ -741,6 +742,7 @@
<string name="force_update_button">Update</string>
<string name="force_update_os_description">Your operating system is out of date. Please update it to continue using the app.</string>
<string name="force_update_os_title">Update Your OS</string>
<string name="force_update_required_action">Update app</string>
<string name="force_update_warning_message">Please update the app to its latest version to ensure proper functionality.</string>
<string name="force_update_warning_title">Update required</string>
<string name="gasless_not_enough_funds_to_cover_token_fee">Not enough funds</string>
@ -2095,8 +2097,11 @@
<string name="tangempay_topup_swap_body">Use crypto from your wallet to top up your payment account</string>
<string name="tangempay_topup_swap_title">From your Tangem Wallet</string>
<string name="tangempay_usdc_on_polygon_network">USDC on Polygon network</string>
<string name="tangempay_va_account_details">Account details</string>
<string name="tangempay_va_available_to_deposit_day">Available to deposit per day:</string>
<string name="tangempay_va_banking_details_error_description">Please try again or contact support if the issue persists</string>
<string name="tangempay_va_banking_details_error_title">Couldn\'t load banking details</string>
<string name="tangempay_va_limit_resetting_everyday">Limit is resetting every day</string>
<string name="tangempay_visa_benefits">Visa Benefits</string>
<string name="tangempay_withdrawal_note_description">Funds from refunded purchases wont be returned your on-chain Polygon balance or be available for withdrawal, but will stay on your card balance for purchases</string>
<string name="tangempay_withdrawal_note_title">Please note</string>

View file

@ -95,6 +95,23 @@ internal class DefaultAddressBookRepository(
decryptContacts(blob, userWallet).find { it.name.value == name }
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun isAddressBookCompatible(userWalletId: UserWalletId?): Flow<Boolean> {
val source = if (userWalletId != null) {
blobStore.getBlob(userWalletId).map { blob -> blob?.isVersionCompatible != false }
} else {
userWalletsListRepository.userWallets
.filterNotNull()
.flatMapLatest { wallets ->
val ids = wallets.mapTo(mutableSetOf()) { it.walletId }
// getBlobs returns only wallets that already have a stored book; wallets without one are
// absent here and count as compatible (a fresh book uses the supported version).
blobStore.getBlobs(ids).map { blobs -> blobs.all { it.isVersionCompatible } }
}
}
return source.distinctUntilChanged().flowOn(dispatchers.default)
}
override suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit> =
withContext(dispatchers.default) {
writeMutex.withLock {
@ -112,6 +129,7 @@ internal class DefaultAddressBookRepository(
writeMutex.withLock {
userWalletsListRepository.userWalletsSync().forEach { userWallet ->
val blob = blobStore.getBlobSync(userWallet.walletId) ?: return@forEach
if (!blob.isVersionCompatible) return@forEach
val addressBook = cipher.decrypt(blob, userWallet).getOrNull() ?: return@forEach
if (addressBook.contacts.none { it.id == id }) return@forEach
@ -171,6 +189,13 @@ internal class DefaultAddressBookRepository(
}
private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List<Contact> {
if (!blob.isVersionCompatible) {
logger.e(
"Skipping address book for wallet ${blob.walletId}: version ${blob.version} is newer than " +
"supported ${AddressBookBlob.CURRENT_VERSION}",
)
return emptyList()
}
return cipher.decrypt(blob, userWallet).fold(
ifLeft = { error ->
// The cipher already logged the low-level cause; this ties the failure to the read path so QA
@ -195,6 +220,13 @@ internal class DefaultAddressBookRepository(
userWallet: UserWallet,
): Either<AddressBookSyncError, List<Contact>> {
val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList<Contact>().right()
if (!blob.isVersionCompatible) {
logger.e(
"Refusing to overwrite address book for wallet $userWalletId: stored version ${blob.version} is " +
"newer than supported ${AddressBookBlob.CURRENT_VERSION} — a write would downgrade it",
)
return AddressBookSyncError.VersionMismatch.left()
}
return cipher.decrypt(blob, userWallet)
.map { it.contacts }
.mapLeft { error ->

View file

@ -14,22 +14,13 @@ import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.domain.addressbook.crypto.AddressBookCipher
import com.tangem.domain.addressbook.error.AddressBookCryptoError
import com.tangem.domain.addressbook.error.AddressBookSyncError
import com.tangem.domain.addressbook.model.AddressBook
import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.model.*
import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
@ -402,6 +393,155 @@ internal class DefaultAddressBookRepositoryTest {
assertThat(result).isEqualTo(bob)
}
@Test
fun `GIVEN stored version newer than supported WHEN getContacts THEN emits empty without decrypting`() = runTest {
// Arrange
val blob = createBlob(version = "2.0")
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
// Act
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).isEmpty()
verify(exactly = 0) { cipher.decrypt(any(), any()) }
}
@Test
fun `GIVEN stored version older than supported WHEN getContacts THEN decrypts normally`() = runTest {
// Arrange
val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob(version = "0.9")
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
// Act
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).containsExactly(contact)
}
@Test
fun `GIVEN stored version newer than supported WHEN saveContact THEN VersionMismatch and pushes nothing`() =
runTest {
// Arrange
val storedBlob = createBlob(version = "2.0")
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
// Act
val result = repository.saveContact(createContact(id = "c2", name = "Bob"))
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.VersionMismatch.left())
verify(exactly = 0) { cipher.decrypt(any(), any()) }
coVerify(exactly = 0) { addressBookApi.updateAddressBook(any(), any(), any()) }
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
}
@Test
fun `GIVEN stored version older than supported WHEN saveContact THEN pushes upgraded to current version`() =
runTest {
// Arrange
val storedBlob = createBlob(version = "0.9")
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns AddressBook(emptyList()).right()
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { blobStore.storeBlob(any()) } returns Unit
val bodySlot = slot<com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest>()
coEvery {
addressBookApi.updateAddressBook(WALLET_A, any(), capture(bodySlot))
} returns successPutResponse()
// Act
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
assertThat(result).isEqualTo(Unit.right())
assertThat(bodySlot.captured.version).isEqualTo(AddressBookBlob.CURRENT_VERSION)
}
@Test
fun `GIVEN contact wallet version newer than supported WHEN deleteContact THEN book left untouched`() = runTest {
// Arrange
val storedBlob = createBlob(version = "2.0")
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
// Act
val result = repository.deleteContact(ContactId("c2"))
// Assert — the newer book is skipped, so nothing is decrypted, pushed or stored.
assertThat(result).isEqualTo(Unit.right())
verify(exactly = 0) { cipher.decrypt(any(), any()) }
coVerify(exactly = 0) { addressBookApi.updateAddressBook(any(), any(), any()) }
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
}
@Test
fun `GIVEN no book WHEN isAddressBookCompatible for wallet THEN true`() = runTest {
// Arrange
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(null)
// Act
val result = repository.isAddressBookCompatible(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).isTrue()
}
@Test
fun `GIVEN version at or below supported WHEN isAddressBookCompatible for wallet THEN true`() = runTest {
// Arrange
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(createBlob(version = "0.9"))
// Act
val result = repository.isAddressBookCompatible(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).isTrue()
}
@Test
fun `GIVEN version newer than supported WHEN isAddressBookCompatible for wallet THEN false`() = runTest {
// Arrange
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(createBlob(version = "2.0"))
// Act
val result = repository.isAddressBookCompatible(UserWalletId(WALLET_A)).first()
// Assert
assertThat(result).isFalse()
}
@Test
fun `GIVEN one wallet book newer WHEN isAddressBookCompatible for all THEN false`() = runTest {
// Arrange
val walletB: UserWallet = mockk { every { walletId } returns UserWalletId(WALLET_B) }
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet, walletB))
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A), UserWalletId(WALLET_B))) } returns
flowOf(listOf(createBlob(walletId = WALLET_A), createBlob(walletId = WALLET_B, version = "2.0")))
// Act
val result = repository.isAddressBookCompatible().first()
// Assert
assertThat(result).isFalse()
}
@Test
fun `GIVEN all wallet books supported WHEN isAddressBookCompatible for all THEN true`() = runTest {
// Arrange
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns
flowOf(listOf(createBlob(version = "0.9")))
// Act
val result = repository.isAddressBookCompatible().first()
// Assert
assertThat(result).isTrue()
}
private fun successPutResponse(etag: String = ETAG_NEW): ApiResponse<UpdateAddressBookResponse> =
ApiResponse.Success(
data = UpdateAddressBookResponse(walletId = WALLET_A, etag = etag, updatedAt = TIMESTAMP),
@ -438,8 +578,12 @@ internal class DefaultAddressBookRepositoryTest {
addresses = emptyList(),
)
private fun createBlob(): AddressBookBlob = AddressBookBlob(
walletId = WALLET_A,
private fun createBlob(
walletId: String = WALLET_A,
version: String = AddressBookBlob.CURRENT_VERSION,
): AddressBookBlob = AddressBookBlob(
version = version,
walletId = walletId,
updatedAt = TIMESTAMP,
nonce = "00112233445566778899aabb",
ciphertext = "deadbeef",

View file

@ -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
}

View file

@ -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()
}
}
}

View file

@ -20,6 +20,14 @@ interface AddressBookRepository {
suspend fun getContact(userWalletId: UserWalletId, name: String): Contact?
/**
* Whether the stored address book(s) can be used by this build i.e. their contract version is not newer
* than the one this app supports (see [com.tangem.domain.addressbook.model.AddressBookBlob.isVersionCompatible]).
* @param userWalletId a specific wallet, or `null` to check every wallet `null` is compatible only when
* **all** currently stored books are compatible.
*/
fun isAddressBookCompatible(userWalletId: UserWalletId? = null): Flow<Boolean>
suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit>
suspend fun deleteContact(id: ContactId): Either<AddressBookSyncError, Unit>

View file

@ -0,0 +1,20 @@
package com.tangem.domain.addressbook.usecase
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
/**
* Emits whether the stored address book(s) can be used by this build `false` when the backend contract
* version is newer than the one this app supports, so consumers can degrade (hide the book, block editing).
*
* @param userWalletId a specific wallet, or `null` to check every wallet (compatible only when all stored
* books are compatible). See [AddressBookRepository.isAddressBookCompatible].
*/
class IsAddressBookCompatibleUseCase(
private val repository: AddressBookRepository,
) {
operator fun invoke(userWalletId: UserWalletId? = null): Flow<Boolean> =
repository.isAddressBookCompatible(userWalletId)
}

View file

@ -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
)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

@ -0,0 +1,13 @@
package com.tangem.features.addressbook.list.state.transformers
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.utils.transformer.Transformer
/**
* Replaces the whole list state with [AddressBookListUM.Incompatible] used when a stored book uses a
* contract version newer than this build supports.
*/
internal class SetAddressBookIncompatibleTransformer : Transformer<AddressBookListUM> {
override fun transform(prevState: AddressBookListUM): AddressBookListUM = AddressBookListUM.Incompatible
}

View file

@ -14,6 +14,7 @@ internal class UpdateAddressBookListQueryTransformer(
)
is AddressBookListUM.Empty,
AddressBookListUM.Loading,
AddressBookListUM.Incompatible,
-> prevState
}
}

View file

@ -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 = {})
}
}

View file

@ -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
/**

View file

@ -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 }

View file

@ -18,6 +18,7 @@ import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAdd
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.usecase.IsAddressBookCompatibleUseCase
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.account.AccountStatus
@ -88,6 +89,7 @@ internal class SendDestinationModel @Inject constructor(
private val addressBookSendAnalytics: AddressBookSendAnalytics,
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
private val addressBookFeatureToggles: AddressBookFeatureToggles,
isAddressBookCompatibleUseCase: IsAddressBookCompatibleUseCase,
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
contactSelectionListener: ContactSelectionListener,
) : Model(), SendDestinationClickIntents {
@ -105,6 +107,11 @@ internal class SendDestinationModel @Inject constructor(
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, emptyList())
private val isAddressBookCompatible: StateFlow<Boolean> =
isAddressBookCompatibleUseCase(userWalletId)
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = true)
val addressSelectorNavigation = SlotNavigation<MatchedContact>()
val addressQuery: StateFlow<String> = uiState
.map { (it as? DestinationUM.Content)?.addressTextField?.value.orEmpty() }
@ -118,7 +125,8 @@ internal class SendDestinationModel @Inject constructor(
*/
val showAddContact: StateFlow<Boolean> =
if ((params as? DestinationBlockParams)?.isAddContactAvailable == true) {
combine(uiState, contacts) { state, contactList ->
combine(uiState, contacts, isAddressBookCompatible) { state, contactList, isCompatible ->
if (!isCompatible) return@combine false
val address = (state as? DestinationUM.Content)?.addressTextField ?: return@combine false
if (address.contactName != null) return@combine false // sent via a contact
val networkId = cryptoCurrency.network.rawId

View file

@ -16,6 +16,7 @@ import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAdd
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
import com.tangem.domain.addressbook.model.*
import com.tangem.domain.addressbook.usecase.IsAddressBookCompatibleUseCase
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
import com.tangem.domain.models.currency.CryptoCurrency
@ -94,6 +95,7 @@ internal class SendDestinationModelTest {
private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true)
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor = mockk(relaxed = true)
private val syncAddressBooksUseCase: SyncAddressBooksUseCase = mockk(relaxed = true)
private val isAddressBookCompatibleUseCase: IsAddressBookCompatibleUseCase = mockk(relaxed = true)
private val addressBookFeatureToggles: AddressBookFeatureToggles = mockk(relaxed = true)
private val contactSelectionListener: ContactSelectionListener = mockk(relaxed = true)
private val addressBookSendAnalytics: AddressBookSendAnalytics = mockk(relaxed = true)
@ -490,6 +492,25 @@ internal class SendDestinationModelTest {
assertThat(sut.showAddContact.value).isEqualTo(model.expectedShown)
}
@Test
fun `GIVEN book newer than supported WHEN fresh address entered THEN add-contact hidden`() = runTest {
// Arrange
coEvery {
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), any())
} returns AddressValidation.Success.Valid.right()
every { getVerifiedContactsInteractor.getVerifiedContacts(any(), any()) } returns flowOf(emptyList())
every { isAddressBookCompatibleUseCase(testUserWalletId) } returns flowOf(false)
val sut = buildBlockModel(isAddContactAvailable = true)
advanceUntilIdle()
// Act
sut.onRecipientAddressValueChange("0xFresh", EnterAddressSource.InputField)
advanceUntilIdle()
// Assert — a newer book can't be written, so saving the recipient is never offered.
assertThat(sut.showAddContact.value).isFalse()
}
private fun provideTestModels() = listOf(
// not available -> never shown, even for a fresh valid address
AddContactModel(
@ -667,6 +688,7 @@ internal class SendDestinationModelTest {
addressBookSendAnalytics = addressBookSendAnalytics,
syncAddressBooksUseCase = syncAddressBooksUseCase,
addressBookFeatureToggles = addressBookFeatureToggles,
isAddressBookCompatibleUseCase = isAddressBookCompatibleUseCase,
getVerifiedContactsInteractor = getVerifiedContactsInteractor,
contactSelectionListener = contactSelectionListener,
)