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