Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-26 14:53:39 +01:00
parent 6ca26f33d1
commit 3815e5fdf3
20 changed files with 537 additions and 95 deletions

View file

@ -0,0 +1,25 @@
package com.tangem.datasource.api.addressbook
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookResponse
import com.tangem.datasource.api.common.response.ApiResponse
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.PUT
import retrofit2.http.POST
import retrofit2.http.Path
interface AddressBookApi {
@POST("v1/address-books/sync")
suspend fun syncAddressBooks(@Body body: SyncAddressBooksRequest): ApiResponse<SyncAddressBooksResponse>
@PUT("v1/address-books/{walletId}")
suspend fun updateAddressBook(
@Path("walletId") walletId: String,
@Header("If-Match") eTag: String?,
@Body body: UpdateAddressBookRequest,
): ApiResponse<UpdateAddressBookResponse>
}

View file

@ -0,0 +1,22 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for `POST /address-books/sync`.
*
* Each [Wallet.etag] is optional: when it matches the backend's etag, that wallet is omitted from the
* response and the local copy is kept.
*/
@JsonClass(generateAdapter = true)
data class SyncAddressBooksRequest(
@Json(name = "wallets") val wallets: List<Wallet>,
) {
@JsonClass(generateAdapter = true)
data class Wallet(
@Json(name = "walletId") val walletId: String,
@Json(name = "etag") val etag: String? = null,
)
}

View file

@ -0,0 +1,27 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response body for `POST /address-books/sync`.
*
* [items] contains only the wallets whose backend etag differs from the one sent in the request; wallets
* with a matching etag are omitted and their local copy must be kept.
*/
@JsonClass(generateAdapter = true)
data class SyncAddressBooksResponse(
@Json(name = "items") val items: List<Item>,
) {
@JsonClass(generateAdapter = true)
data class Item(
@Json(name = "walletId") val walletId: String,
@Json(name = "etag") val etag: String,
@Json(name = "version") val version: String,
@Json(name = "updatedAt") val updatedAt: String,
@Json(name = "nonce") val nonce: String,
@Json(name = "ciphertext") val ciphertext: String,
@Json(name = "authTag") val authTag: String,
)
}

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Request body for `PUT /address-books/{walletId}`. */
@JsonClass(generateAdapter = true)
data class UpdateAddressBookRequest(
@Json(name = "version") val version: String,
@Json(name = "nonce") val nonce: String,
@Json(name = "ciphertext") val ciphertext: String,
@Json(name = "authTag") val authTag: String,
)

View file

@ -0,0 +1,12 @@
package com.tangem.datasource.api.addressbook.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Response body for `PUT /address-books/{walletId}`. */
@JsonClass(generateAdapter = true)
data class UpdateAddressBookResponse(
@Json(name = "walletId") val walletId: String,
@Json(name = "etag") val etag: String,
@Json(name = "updatedAt") val updatedAt: String,
)

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.addressbook.AddressBookApi
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.surveysparrow.SurveySparrowApi
@ -118,6 +119,16 @@ internal object NetworkModule {
)
}
@Provides
@Singleton
fun provideAddressBookApi(retrofitApiBuilder: RetrofitApiBuilder): AddressBookApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.TangemTech,
applyTimeoutAnnotations = false,
sessionAuth = false,
)
}
@Provides
@Singleton
fun provideYieldSupplyApi(retrofitApiBuilder: RetrofitApiBuilder): YieldSupplyApi {

View file

@ -16,6 +16,10 @@ dependencies {
implementation(projects.core.utils)
// endregion
// region Project - Data
implementation(projects.data.common)
// endregion
// region Project - Domain
implementation(projects.domain.addressBook)
implementation(projects.domain.common)

View file

@ -1,7 +1,20 @@
package com.tangem.data.addressbook
import arrow.core.Either
import arrow.core.flatMap
import arrow.core.left
import arrow.core.right
import com.tangem.data.addressbook.store.AddressBookBlobStore
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.datasource.api.addressbook.AddressBookApi
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
import com.tangem.domain.addressbook.crypto.AddressBookCipher
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
@ -12,6 +25,7 @@ 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.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
@ -19,14 +33,18 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.joda.time.DateTime
@Suppress("LongParameterList")
internal class DefaultAddressBookRepository(
private val blobStore: AddressBookBlobStore,
private val cipher: AddressBookCipher,
private val addressBookApi: AddressBookApi,
private val eTagsStore: ETagsStore,
private val userWalletsListRepository: UserWalletsListRepository,
private val timestampProvider: IsoTimestampProvider,
private val dispatchers: CoroutineDispatcherProvider,
@ -36,6 +54,7 @@ internal class DefaultAddressBookRepository(
override fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>> {
return getContactsForWallet(userWalletId)
.onStart { syncAddressBooks() }
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
@ -55,6 +74,7 @@ internal class DefaultAddressBookRepository(
}
}
}
.onStart { syncAddressBooks() }
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
@ -73,16 +93,19 @@ internal class DefaultAddressBookRepository(
decryptContacts(blob, userWallet).find { it.name.value == name }
}
override suspend fun saveContact(contact: Contact) = withContext(dispatchers.default) {
override suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit> =
withContext(dispatchers.default) {
writeMutex.withLock {
val userWallet = findUserWallet(contact.walletId.stringValue) ?: return@withLock
val userWallet = findUserWallet(contact.walletId.stringValue)
?: return@withLock AddressBookSyncError.Unknown.left()
val current = currentContacts(contact.walletId, userWallet)
val merged = current.filterNot { it.id == contact.id } + contact
persist(userWallet, AddressBook(walletId = contact.walletId, contacts = merged))
}
}
override suspend fun deleteContact(id: ContactId) = withContext(dispatchers.default) {
override suspend fun deleteContact(id: ContactId): Either<AddressBookSyncError, Unit> =
withContext(dispatchers.default) {
writeMutex.withLock {
userWalletsListRepository.userWalletsSync().forEach { userWallet ->
val blob = blobStore.getBlobSync(userWallet.walletId) ?: return@forEach
@ -90,10 +113,48 @@ internal class DefaultAddressBookRepository(
if (addressBook.contacts.none { it.id == id }) return@forEach
val remaining = addressBook.contacts.filterNot { it.id == id }
persist(userWallet, addressBook.copy(contacts = remaining))
return@withLock
return@withLock persist(userWallet, addressBook.copy(contacts = remaining))
}
// No wallet held the contact — nothing to push, treat as success.
Unit.right()
}
}
override suspend fun syncAddressBooks(): Either<AddressBookSyncError, Unit> = withContext(dispatchers.default) {
val wallets = userWalletsListRepository.userWalletsSync()
// The backend rejects more than MAX_SYNC_WALLETS per request, so sync in chunks and stop on the
// first failed chunk.
wallets.chunked(MAX_SYNC_WALLETS)
.fold(initial = Unit.right() as Either<AddressBookSyncError, Unit>) { acc, chunk ->
acc.flatMap { syncWalletsChunk(chunk) }
}
}
private suspend fun syncWalletsChunk(wallets: List<UserWallet>): Either<AddressBookSyncError, Unit> {
val request = SyncAddressBooksRequest(
wallets = wallets.map { wallet ->
SyncAddressBooksRequest.Wallet(
walletId = wallet.walletId.stringValue,
etag = eTagsStore.getSyncOrNull(wallet.walletId, ETagsStore.Key.AddressBook),
)
},
)
return safeApiCall(
call = {
val response = withContext(dispatchers.io) { addressBookApi.syncAddressBooks(request).bind() }
// Only wallets whose etag changed are returned; the rest keep their local copy.
response.items.forEach { item ->
val userWalletId = UserWalletId(stringValue = item.walletId)
blobStore.storeBlob(item.toBlob())
eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, item.etag)
}
Unit.right()
},
onError = { error ->
TangemLogger.e(messageString = "Failed to sync address books: $error")
error.toSyncError().left()
},
)
}
private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List<Contact> {
@ -105,12 +166,80 @@ internal class DefaultAddressBookRepository(
return decryptContacts(blob, userWallet)
}
private suspend fun persist(userWallet: UserWallet, addressBook: AddressBook) {
/**
* Encrypts [addressBook], pushes it to the backend, and persists it locally **only** on success.
* On any failure (encryption, network, etag conflict, ) nothing is written locally.
*/
private suspend fun persist(userWallet: UserWallet, addressBook: AddressBook): Either<AddressBookSyncError, Unit> {
val updatedAt = DateTime.parse(timestampProvider.now())
cipher.encrypt(addressBook, userWallet, updatedAt)
.onRight { blobStore.storeBlob(it) }
return cipher.encrypt(addressBook, userWallet, updatedAt)
.mapLeft { error ->
TangemLogger.e(
messageString = "Failed to encrypt address book for wallet ${userWallet.walletId}: $error",
)
AddressBookSyncError.Unknown
}
.flatMap { blob -> pushBlob(addressBook.walletId, blob) }
}
private suspend fun pushBlob(
userWalletId: UserWalletId,
blob: AddressBookBlob,
): Either<AddressBookSyncError, Unit> {
// Absent etag means the book has not been created on the backend yet → omit If-Match to create it.
val eTag = eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.AddressBook)
return safeApiCall(
call = {
val response = withContext(dispatchers.io) {
addressBookApi.updateAddressBook(
walletId = blob.walletId,
eTag = eTag,
body = UpdateAddressBookRequest(
version = blob.version,
nonce = blob.nonce,
ciphertext = blob.ciphertext,
authTag = blob.authTag,
),
).bind()
}
blobStore.storeBlob(blob)
eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, response.etag)
Unit.right()
},
onError = { error ->
TangemLogger.e(messageString = "Failed to push address book for wallet $userWalletId: $error")
error.toSyncError().left()
},
)
}
private fun SyncAddressBooksResponse.Item.toBlob(): AddressBookBlob = AddressBookBlob(
version = version,
walletId = walletId,
updatedAt = updatedAt,
nonce = nonce,
ciphertext = ciphertext,
authTag = authTag,
)
private fun ApiResponseError.toSyncError(): AddressBookSyncError = when (this) {
is ApiResponseError.HttpException -> when (code) {
Code.PRECONDITION_FAILED -> AddressBookSyncError.Conflict
Code.NOT_FOUND -> AddressBookSyncError.NotFound
Code.UNAUTHORIZED -> AddressBookSyncError.Unauthorized
Code.BAD_REQUEST -> AddressBookSyncError.BadRequest
else -> AddressBookSyncError.Unknown
}
is ApiResponseError.NetworkException,
is ApiResponseError.TimeoutException,
-> AddressBookSyncError.Network
is ApiResponseError.UnknownException -> AddressBookSyncError.Unknown
}
private suspend fun findUserWallet(walletId: String): UserWallet? =
userWalletsListRepository.userWalletsSync().find { it.walletId.stringValue == walletId }
private companion object {
const val MAX_SYNC_WALLETS = 20
}
}

View file

@ -6,9 +6,11 @@ import androidx.datastore.dataStoreFile
import com.tangem.data.addressbook.DefaultAddressBookRepository
import com.tangem.data.addressbook.store.AddressBookBlobStore
import com.tangem.data.addressbook.store.DefaultAddressBookBlobStore
import com.tangem.data.addressbook.store.StoredAddressBookBlob
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.datasource.api.addressbook.AddressBookApi
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
import com.tangem.domain.addressbook.crypto.AddressBookCipher
import com.tangem.domain.addressbook.model.AddressBookBlob
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.common.wallets.UserWalletsListRepository
@ -39,7 +41,7 @@ internal object AddressBookDataModule {
defaultValue = emptyMap(),
serializer = MapSerializer(
keySerializer = String.serializer(),
valueSerializer = StoredAddressBookBlob.serializer(),
valueSerializer = AddressBookBlob.serializer(),
),
),
produceFile = { context.dataStoreFile(fileName = "address_book_blobs") },
@ -50,9 +52,12 @@ internal object AddressBookDataModule {
@Provides
@Singleton
@Suppress("LongParameterList")
fun provideAddressBookRepository(
blobStore: AddressBookBlobStore,
cipher: AddressBookCipher,
addressBookApi: AddressBookApi,
eTagsStore: ETagsStore,
userWalletsListRepository: UserWalletsListRepository,
timestampProvider: IsoTimestampProvider,
dispatchers: CoroutineDispatcherProvider,
@ -60,6 +65,8 @@ internal object AddressBookDataModule {
return DefaultAddressBookRepository(
blobStore = blobStore,
cipher = cipher,
addressBookApi = addressBookApi,
eTagsStore = eTagsStore,
userWalletsListRepository = userWalletsListRepository,
timestampProvider = timestampProvider,
dispatchers = dispatchers,

View file

@ -12,14 +12,7 @@ interface AddressBookBlobStore {
suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob?
/** Persists [blob] optimistically with `isBESynchronized = false`. Keyed by [AddressBookBlob.walletId]. */
suspend fun storeBlob(blob: AddressBookBlob)
/** Flips the BE-sync flag to `true` once the backend confirms the push. No-op if the blob is absent. */
suspend fun markAsSynchronized(userWalletId: UserWalletId)
/** Blobs still pending a backend push — the entry point for the future sync service. */
suspend fun getUnsynchronizedBlobs(): List<AddressBookBlob>
suspend fun deleteBlob(userWalletId: UserWalletId)
}

View file

@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
internal typealias AddressBookBlobs = Map<String, StoredAddressBookBlob>
internal typealias AddressBookBlobs = Map<String, AddressBookBlob>
internal class DefaultAddressBookBlobStore(
private val dataStore: DataStore<AddressBookBlobs>,
@ -16,38 +16,23 @@ internal class DefaultAddressBookBlobStore(
override fun getBlob(userWalletId: UserWalletId): Flow<AddressBookBlob?> {
return dataStore.data
.map { it[userWalletId.stringValue]?.blob }
.map { it[userWalletId.stringValue] }
.distinctUntilChanged()
}
override fun getBlobs(userWalletIds: Set<UserWalletId>): Flow<List<AddressBookBlob>> {
val ids = userWalletIds.mapTo(mutableSetOf()) { it.stringValue }
return dataStore.data
.map { stored -> stored.filterKeys { it in ids }.values.map { it.blob } }
.map { stored -> stored.filterKeys { it in ids }.values.toList() }
.distinctUntilChanged()
}
override suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob? {
return getStoredBlobs()[userWalletId.stringValue]?.blob
return getStoredBlobs()[userWalletId.stringValue]
}
override suspend fun storeBlob(blob: AddressBookBlob) {
dataStore.updateData { stored ->
stored + (blob.walletId to StoredAddressBookBlob(blob = blob, isBESynchronized = false))
}
}
override suspend fun markAsSynchronized(userWalletId: UserWalletId) {
dataStore.updateData { stored ->
val current = stored[userWalletId.stringValue] ?: return@updateData stored
stored + (userWalletId.stringValue to current.copy(isBESynchronized = true))
}
}
override suspend fun getUnsynchronizedBlobs(): List<AddressBookBlob> {
return getStoredBlobs().values
.filterNot { it.isBESynchronized }
.map { it.blob }
dataStore.updateData { stored -> stored + (blob.walletId to blob) }
}
override suspend fun deleteBlob(userWalletId: UserWalletId) {

View file

@ -1,15 +0,0 @@
package com.tangem.data.addressbook.store
import com.tangem.domain.addressbook.model.AddressBookBlob
import kotlinx.serialization.Serializable
/**
* [isBESynchronized] tracks whether the blob has already been pushed to the backend. A freshly
* stored blob is written optimistically with `false`; a future BE-sync service flips it to `true`
* once the push is confirmed.
*/
@Serializable
internal data class StoredAddressBookBlob(
val blob: AddressBookBlob,
val isBESynchronized: Boolean,
)

View file

@ -4,8 +4,17 @@ import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.data.addressbook.store.AddressBookBlobStore
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.datasource.api.addressbook.AddressBookApi
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookResponse
import com.tangem.datasource.api.common.response.ApiResponse
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
@ -19,6 +28,7 @@ import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
@ -35,6 +45,8 @@ internal class DefaultAddressBookRepositoryTest {
private val blobStore: AddressBookBlobStore = mockk()
private val cipher: AddressBookCipher = mockk()
private val addressBookApi: AddressBookApi = mockk()
private val eTagsStore: ETagsStore = mockk(relaxed = true)
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val timestampProvider: IsoTimestampProvider = mockk()
@ -45,6 +57,8 @@ internal class DefaultAddressBookRepositoryTest {
private val repository = DefaultAddressBookRepository(
blobStore = blobStore,
cipher = cipher,
addressBookApi = addressBookApi,
eTagsStore = eTagsStore,
userWalletsListRepository = userWalletsListRepository,
timestampProvider = timestampProvider,
dispatchers = TestingCoroutineDispatcherProvider(),
@ -52,9 +66,11 @@ internal class DefaultAddressBookRepositoryTest {
@BeforeEach
fun setup() {
clearMocks(blobStore, cipher, userWalletsListRepository, timestampProvider)
clearMocks(blobStore, cipher, addressBookApi, eTagsStore, userWalletsListRepository, timestampProvider)
every { timestampProvider.now() } returns TIMESTAMP
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
coEvery { addressBookApi.syncAddressBooks(any()) } returns
ApiResponse.Success(SyncAddressBooksResponse(items = emptyList()))
}
@Test
@ -72,6 +88,24 @@ internal class DefaultAddressBookRepositoryTest {
assertThat(result).containsExactly(contact)
}
@Test
fun `GIVEN blob WHEN getContacts THEN syncs before reading contacts`() = runTest {
// Arrange
val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob()
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right()
// Act
repository.getContacts(UserWalletId(WALLET_A)).first()
// Assert
coVerifyOrder {
addressBookApi.syncAddressBooks(any())
cipher.decrypt(blob, userWallet)
}
}
@Test
fun `GIVEN multiple wallets WHEN getAllContacts THEN emits contacts from all wallets`() = runTest {
// Arrange
@ -88,6 +122,25 @@ internal class DefaultAddressBookRepositoryTest {
assertThat(result).containsExactly(contact)
}
@Test
fun `GIVEN blob WHEN getAllContacts THEN syncs before reading contacts`() = runTest {
// Arrange
val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob()
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob))
every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right()
// Act
repository.getAllContacts().first()
// Assert
coVerifyOrder {
addressBookApi.syncAddressBooks(any())
cipher.decrypt(blob, userWallet)
}
}
@Test
fun `GIVEN no blob WHEN getContacts THEN emits empty`() = runTest {
// Arrange
@ -115,7 +168,7 @@ internal class DefaultAddressBookRepositoryTest {
}
@Test
fun `GIVEN new contact WHEN saveContact THEN encrypts merged book and stores blob`() = runTest {
fun `GIVEN backend accepts WHEN saveContact THEN pushes merged book and stores blob and etag`() = runTest {
// Arrange
val existing = createContact(id = "c1", name = "Alice")
val added = createContact(id = "c2", name = "Bob")
@ -127,13 +180,82 @@ internal class DefaultAddressBookRepositoryTest {
val newBlob = createBlob()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right()
coEvery { blobStore.storeBlob(newBlob) } returns Unit
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
// Act
repository.saveContact(added)
val result = repository.saveContact(added)
// Assert
assertThat(result).isEqualTo(Unit.right())
assertThat(bookSlot.captured.contacts).containsExactly(existing, added)
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) }
}
@Test
fun `GIVEN no stored etag WHEN saveContact THEN PUT is sent without If-Match`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
val newBlob = createBlob()
every { cipher.encrypt(any(), userWallet, any()) } returns newBlob.right()
coEvery { blobStore.storeBlob(any()) } returns Unit
coEvery { eTagsStore.getSyncOrNull(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook) } returns null
coEvery { addressBookApi.updateAddressBook(WALLET_A, null, any()) } returns successPutResponse()
// Act
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
assertThat(result).isEqualTo(Unit.right())
coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, null, any()) }
}
@Test
fun `GIVEN stored etag WHEN saveContact THEN PUT carries it in If-Match`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { blobStore.storeBlob(any()) } returns Unit
coEvery { eTagsStore.getSyncOrNull(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook) } returns ETAG_OLD
coEvery { addressBookApi.updateAddressBook(WALLET_A, ETAG_OLD, any()) } returns successPutResponse()
// Act
repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, ETAG_OLD, any()) }
}
@Test
fun `GIVEN etag conflict WHEN saveContact THEN returns Conflict and does not store locally`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns
errorResponse(ApiResponseError.HttpException.Code.PRECONDITION_FAILED)
// Act
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.Conflict.left())
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
coVerify(exactly = 0) { eTagsStore.store(any(), any(), any()) }
}
@Test
fun `GIVEN no network WHEN saveContact THEN returns Network and does not store locally`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns networkErrorResponse()
// Act
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.Network.left())
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
}
@Test
@ -148,6 +270,7 @@ internal class DefaultAddressBookRepositoryTest {
val bookSlot = slot<AddressBook>()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right()
coEvery { blobStore.storeBlob(any()) } returns Unit
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
// Act
repository.saveContact(updated)
@ -157,7 +280,7 @@ internal class DefaultAddressBookRepositoryTest {
}
@Test
fun `GIVEN contact in wallet WHEN deleteContact THEN re-stores book without it`() = runTest {
fun `GIVEN contact in wallet WHEN deleteContact THEN pushes and re-stores book without it`() = runTest {
// Arrange
val kept = createContact(id = "c1", name = "Alice")
val removed = createContact(id = "c2", name = "Bob")
@ -169,15 +292,55 @@ internal class DefaultAddressBookRepositoryTest {
val newBlob = createBlob()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right()
coEvery { blobStore.storeBlob(newBlob) } returns Unit
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
// Act
repository.deleteContact(ContactId("c2"))
val result = repository.deleteContact(ContactId("c2"))
// Assert
assertThat(result).isEqualTo(Unit.right())
assertThat(bookSlot.captured.contacts).containsExactly(kept)
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
}
@Test
fun `GIVEN backend returns changed item WHEN syncAddressBooks THEN stores blob and etag for it`() = runTest {
// Arrange
val walletB: UserWallet = mockk { every { walletId } returns UserWalletId(WALLET_B) }
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet, walletB)
val requestSlot = slot<SyncAddressBooksRequest>()
// Only wallet A changed; wallet B is omitted (matching etag) → must keep its local copy.
coEvery { addressBookApi.syncAddressBooks(capture(requestSlot)) } returns
ApiResponse.Success(SyncAddressBooksResponse(items = listOf(syncItem(WALLET_A))))
val blobSlot = slot<AddressBookBlob>()
coEvery { blobStore.storeBlob(capture(blobSlot)) } returns Unit
// Act
val result = repository.syncAddressBooks()
// Assert
assertThat(result).isEqualTo(Unit.right())
assertThat(requestSlot.captured.wallets.map { it.walletId }).containsExactly(WALLET_A, WALLET_B)
assertThat(blobSlot.captured.walletId).isEqualTo(WALLET_A)
coVerify(exactly = 1) { blobStore.storeBlob(any()) }
coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) }
coVerify(exactly = 0) { blobStore.storeBlob(match { it.walletId == WALLET_B }) }
}
@Test
fun `GIVEN unauthorized WHEN syncAddressBooks THEN returns Unauthorized and stores nothing`() = runTest {
// Arrange
coEvery { addressBookApi.syncAddressBooks(any()) } returns
errorResponse(ApiResponseError.HttpException.Code.UNAUTHORIZED)
// Act
val result = repository.syncAddressBooks()
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.Unauthorized.left())
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
}
@Test
fun `GIVEN matching name WHEN getContact THEN returns it`() = runTest {
// Arrange
@ -195,6 +358,31 @@ internal class DefaultAddressBookRepositoryTest {
assertThat(result).isEqualTo(bob)
}
private fun successPutResponse(etag: String = ETAG_NEW): ApiResponse<UpdateAddressBookResponse> =
ApiResponse.Success(
data = UpdateAddressBookResponse(walletId = WALLET_A, etag = etag, updatedAt = TIMESTAMP),
)
@Suppress("UNCHECKED_CAST")
private fun <T : Any> errorResponse(code: ApiResponseError.HttpException.Code): ApiResponse<T> =
ApiResponse.Error(
cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null),
) as ApiResponse<T>
@Suppress("UNCHECKED_CAST")
private fun <T : Any> networkErrorResponse(): ApiResponse<T> =
ApiResponse.Error(cause = ApiResponseError.NetworkException()) as ApiResponse<T>
private fun syncItem(walletId: String): SyncAddressBooksResponse.Item = SyncAddressBooksResponse.Item(
walletId = walletId,
etag = ETAG_NEW,
version = AddressBookBlob.CURRENT_VERSION,
updatedAt = TIMESTAMP,
nonce = "00112233445566778899aabb",
ciphertext = "deadbeef",
authTag = "cafebabecafebabecafebabecafebabe",
)
private fun createContact(id: String, name: String, iconColor: String = "KekColor"): Contact = Contact(
id = ContactId(id),
walletId = UserWalletId(WALLET_A),
@ -216,6 +404,9 @@ internal class DefaultAddressBookRepositoryTest {
private companion object {
const val WALLET_A = "0a0a0a"
const val WALLET_B = "0b0b0b"
const val TIMESTAMP = "2026-05-22T09:00:00.000Z"
const val ETAG_OLD = "etag-old"
const val ETAG_NEW = "etag-new"
}
}

View file

@ -23,7 +23,7 @@ internal class DefaultAddressBookBlobStoreTest {
}
@Test
fun `GIVEN blob WHEN storeBlob THEN getBlob emits it AND it is unsynchronized`() = runTest {
fun `GIVEN blob WHEN storeBlob THEN getBlob emits it`() = runTest {
// Arrange
val blob = createBlob(walletId = WALLET_A)
@ -33,21 +33,6 @@ internal class DefaultAddressBookBlobStoreTest {
// Assert
assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isEqualTo(blob)
assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isEqualTo(blob)
assertThat(store.getUnsynchronizedBlobs()).containsExactly(blob)
}
@Test
fun `GIVEN stored blob WHEN markAsSynchronized THEN getUnsynchronizedBlobs excludes it`() = runTest {
// Arrange
val blob = createBlob(walletId = WALLET_A)
store.storeBlob(blob)
// Act
store.markAsSynchronized(UserWalletId(WALLET_A))
// Assert
assertThat(store.getUnsynchronizedBlobs()).isEmpty()
assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isEqualTo(blob)
}
@Test
@ -63,7 +48,6 @@ internal class DefaultAddressBookBlobStoreTest {
// Assert
assertThat(result).isEqualTo(blobA)
assertThat(store.getUnsynchronizedBlobs()).containsExactly(blobA, blobB)
}
@Test

View file

@ -37,5 +37,6 @@ interface ETagsStore {
enum class Key {
WalletAccounts,
UserTokens,
AddressBook,
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.domain.addressbook.error
/**
* Failure of a backend address-book operation (`PUT /address-books/{walletId}` or
* `POST /address-books/sync`). The backend is the source of truth, so when one of these is raised the
* local blob is left untouched.
*/
sealed interface AddressBookSyncError {
/** Etag mismatch on update (HTTP 412) — the book was changed elsewhere. */
data object Conflict : AddressBookSyncError
/** The wallet does not exist on the backend (HTTP 404). */
data object NotFound : AddressBookSyncError
/** Invalid API key (HTTP 401). */
data object Unauthorized : AddressBookSyncError
/** Malformed request or exceeded the wallet limit (HTTP 400). */
data object BadRequest : AddressBookSyncError
/** No network or the request could not be completed. */
data object Network : AddressBookSyncError
/** Any other unexpected failure (encryption, missing data, unmapped HTTP code). */
data object Unknown : AddressBookSyncError
}

View file

@ -10,4 +10,6 @@ sealed interface SaveContactError {
data class Address(val error: AddressValidation.Error) : SaveContactError
data class Signing(val error: SignHashesError) : SaveContactError
data class Backend(val error: AddressBookSyncError) : SaveContactError
}

View file

@ -53,6 +53,8 @@ class SaveContactInteractor(
.mapLeft(SaveContactError::Signing)
.bind()
repository.saveContact(signed)
.mapLeft(SaveContactError::Backend)
.bind()
signed
}
@ -77,6 +79,8 @@ class SaveContactInteractor(
.mapLeft(SaveContactError::Signing)
.bind()
repository.saveContact(signed)
.mapLeft(SaveContactError::Backend)
.bind()
signed
}

View file

@ -1,5 +1,7 @@
package com.tangem.domain.addressbook.repository
import arrow.core.Either
import com.tangem.domain.addressbook.error.AddressBookSyncError
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.models.wallet.UserWalletId
@ -16,8 +18,9 @@ interface AddressBookRepository {
suspend fun getContact(userWalletId: UserWalletId, name: String): Contact?
/** Inserts or updates a [contact]. */
suspend fun saveContact(contact: Contact)
suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit>
suspend fun deleteContact(id: ContactId)
suspend fun deleteContact(id: ContactId): Either<AddressBookSyncError, Unit>
suspend fun syncAddressBooks(): Either<AddressBookSyncError, Unit>
}

View file

@ -4,6 +4,7 @@ import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
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.model.AddressEntry
@ -73,7 +74,7 @@ internal class SaveContactInteractorTest {
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns
signatures.right()
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit
coEvery { repository.saveContact(capture(saved)) } returns Unit.right()
// Act
val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries)
@ -106,7 +107,7 @@ internal class SaveContactInteractorTest {
signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet))
} returns signatures.right()
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit
coEvery { repository.saveContact(capture(saved)) } returns Unit.right()
// Act
interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", twoEntries)
@ -130,7 +131,7 @@ internal class SaveContactInteractorTest {
// Arrange
stubNoExistingContacts()
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit
coEvery { repository.saveContact(capture(saved)) } returns Unit.right()
// Act
val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", emptyList())
@ -204,6 +205,22 @@ internal class SaveContactInteractorTest {
coVerify(exactly = 0) { repository.saveContact(any()) }
}
@Test
fun `GIVEN backend rejects the save WHEN createContact THEN Backend error is propagated`() = runTest {
// Arrange
stubNoExistingContacts()
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns
listOf(byteArrayOf(0x01)).right()
coEvery { repository.saveContact(any()) } returns AddressBookSyncError.Conflict.left()
// Act
val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries)
// Assert
assertThat(result.leftOrNull())
.isEqualTo(SaveContactError.Backend(AddressBookSyncError.Conflict))
}
private fun stubNoExistingContacts() {
every { repository.getContacts(userWallet.walletId) } returns flowOf(emptyList())
}
@ -224,7 +241,7 @@ internal class SaveContactInteractorTest {
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns
signatures.right()
val saved = slot<Contact>()
coEvery { repository.saveContact(capture(saved)) } returns Unit
coEvery { repository.saveContact(capture(saved)) } returns Unit.right()
// Act
val result = interactor.updateContact(