Updated on 2026-08-14
This commit is contained in:
commit
b995f180f1
20 changed files with 139 additions and 37 deletions
|
|
@ -46,6 +46,8 @@ internal class DefaultAddressBookRepository(
|
||||||
|
|
||||||
private val writeMutex = Mutex()
|
private val writeMutex = Mutex()
|
||||||
|
|
||||||
|
private val logger = TangemLogger.withTag(LOG_TAG)
|
||||||
|
|
||||||
override fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>> {
|
override fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>> {
|
||||||
return getContactsForWallet(userWalletId)
|
return getContactsForWallet(userWalletId)
|
||||||
.onStart { syncAddressBooks() }
|
.onStart { syncAddressBooks() }
|
||||||
|
|
@ -124,6 +126,9 @@ internal class DefaultAddressBookRepository(
|
||||||
|
|
||||||
override suspend fun syncAddressBooks(): Either<AddressBookSyncError, Unit> = withContext(dispatchers.default) {
|
override suspend fun syncAddressBooks(): Either<AddressBookSyncError, Unit> = withContext(dispatchers.default) {
|
||||||
val wallets = userWalletsListRepository.userWalletsSync()
|
val wallets = userWalletsListRepository.userWalletsSync()
|
||||||
|
// Debug (Logcat only, dev builds) — the persisted prod log stays quiet on the happy path; only failures
|
||||||
|
// below are written at Error level.
|
||||||
|
logger.d("Syncing address books for ${wallets.size} wallet(s)")
|
||||||
// The backend rejects more than MAX_SYNC_WALLETS per request, so sync in chunks and stop on the
|
// The backend rejects more than MAX_SYNC_WALLETS per request, so sync in chunks and stop on the
|
||||||
// first failed chunk.
|
// first failed chunk.
|
||||||
wallets.chunked(MAX_SYNC_WALLETS)
|
wallets.chunked(MAX_SYNC_WALLETS)
|
||||||
|
|
@ -145,22 +150,38 @@ internal class DefaultAddressBookRepository(
|
||||||
call = {
|
call = {
|
||||||
val response = withContext(dispatchers.io) { addressBookApi.syncAddressBooks(request).bind() }
|
val response = withContext(dispatchers.io) { addressBookApi.syncAddressBooks(request).bind() }
|
||||||
// Only wallets whose etag changed are returned; the rest keep their local copy.
|
// Only wallets whose etag changed are returned; the rest keep their local copy.
|
||||||
|
logger.d("Sync response: ${response.items.size} updated book(s) out of ${wallets.size} requested")
|
||||||
response.items.forEach { item ->
|
response.items.forEach { item ->
|
||||||
val userWalletId = UserWalletId(stringValue = item.walletId)
|
val userWalletId = UserWalletId(stringValue = item.walletId)
|
||||||
|
// Metadata only — helps QA verify what the backend delivered (esp. for cross-platform books).
|
||||||
|
// Debug (Logcat only) to keep the persisted prod log free of happy-path sync noise.
|
||||||
|
logger.d(
|
||||||
|
"Storing synced address book for wallet ${item.walletId}: version=${item.version}, " +
|
||||||
|
"updatedAt=${item.updatedAt}, nonceLen=${item.nonce.length}, " +
|
||||||
|
"ciphertextLen=${item.ciphertext.length}, authTagLen=${item.authTag.length}",
|
||||||
|
)
|
||||||
blobStore.storeBlob(item.toBlob())
|
blobStore.storeBlob(item.toBlob())
|
||||||
eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, item.etag)
|
eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, item.etag)
|
||||||
}
|
}
|
||||||
Unit.right()
|
Unit.right()
|
||||||
},
|
},
|
||||||
onError = { error ->
|
onError = { error ->
|
||||||
TangemLogger.e(messageString = "Failed to sync address books: $error")
|
logger.e("Failed to sync address books: $error")
|
||||||
error.toSyncError().left()
|
error.toSyncError().left()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List<Contact> {
|
private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List<Contact> {
|
||||||
return cipher.decrypt(blob, userWallet).getOrNull()?.contacts.orEmpty()
|
return cipher.decrypt(blob, userWallet).fold(
|
||||||
|
ifLeft = { error ->
|
||||||
|
// The cipher already logged the low-level cause; this ties the failure to the read path so QA
|
||||||
|
// can see that a stored/synced book (e.g. one created on iOS) could not be shown to the user.
|
||||||
|
logger.e("Skipping address book for wallet ${blob.walletId}: decrypt failed with $error")
|
||||||
|
emptyList()
|
||||||
|
},
|
||||||
|
ifRight = { it.contacts },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun currentContacts(userWalletId: UserWalletId, userWallet: UserWallet): List<Contact> {
|
private suspend fun currentContacts(userWalletId: UserWalletId, userWallet: UserWallet): List<Contact> {
|
||||||
|
|
@ -176,9 +197,7 @@ internal class DefaultAddressBookRepository(
|
||||||
val updatedAt = DateTime.parse(timestampProvider.now())
|
val updatedAt = DateTime.parse(timestampProvider.now())
|
||||||
return cipher.encrypt(addressBook, userWallet, updatedAt)
|
return cipher.encrypt(addressBook, userWallet, updatedAt)
|
||||||
.mapLeft { error ->
|
.mapLeft { error ->
|
||||||
TangemLogger.e(
|
logger.e("Failed to encrypt address book for wallet ${userWallet.walletId}: $error")
|
||||||
messageString = "Failed to encrypt address book for wallet ${userWallet.walletId}: $error",
|
|
||||||
)
|
|
||||||
AddressBookSyncError.Unknown
|
AddressBookSyncError.Unknown
|
||||||
}
|
}
|
||||||
.flatMap { blob -> pushBlob(userWallet.walletId, blob) }
|
.flatMap { blob -> pushBlob(userWallet.walletId, blob) }
|
||||||
|
|
@ -209,7 +228,7 @@ internal class DefaultAddressBookRepository(
|
||||||
Unit.right()
|
Unit.right()
|
||||||
},
|
},
|
||||||
onError = { error ->
|
onError = { error ->
|
||||||
TangemLogger.e(messageString = "Failed to push address book for wallet $userWalletId: $error")
|
logger.e("Failed to push address book for wallet $userWalletId: $error")
|
||||||
val syncError = error.toSyncError()
|
val syncError = error.toSyncError()
|
||||||
// A 412 means the local etag is stale relative to the backend. Refresh the local blob + etag so the
|
// A 412 means the local etag is stale relative to the backend. Refresh the local blob + etag so the
|
||||||
// next save attempt (user re-taps Save) starts from the current backend state. We do NOT re-push here
|
// next save attempt (user re-taps Save) starts from the current backend state. We do NOT re-push here
|
||||||
|
|
@ -249,6 +268,7 @@ internal class DefaultAddressBookRepository(
|
||||||
userWalletsListRepository.userWalletsSync().find { it.walletId.stringValue == walletId }
|
userWalletsListRepository.userWalletsSync().find { it.walletId.stringValue == walletId }
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
const val LOG_TAG = "AddressBook"
|
||||||
const val MAX_SYNC_WALLETS = 20
|
const val MAX_SYNC_WALLETS = 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||||
import com.tangem.domain.models.wallet.UserWallet
|
import com.tangem.domain.models.wallet.UserWallet
|
||||||
import com.tangem.utils.extensions.hexToBytesOrNull
|
import com.tangem.utils.extensions.hexToBytesOrNull
|
||||||
import com.tangem.utils.extensions.toHexString
|
import com.tangem.utils.extensions.toHexString
|
||||||
|
import com.tangem.utils.logging.TangemLogger
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.joda.time.DateTime
|
import org.joda.time.DateTime
|
||||||
import org.joda.time.DateTimeZone
|
import org.joda.time.DateTimeZone
|
||||||
|
|
@ -35,6 +36,7 @@ class AddressBookCipher {
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
private val secureRandom = SecureRandom()
|
private val secureRandom = SecureRandom()
|
||||||
|
private val logger = TangemLogger.withTag(LOG_TAG)
|
||||||
|
|
||||||
fun encrypt(
|
fun encrypt(
|
||||||
addressBook: AddressBook,
|
addressBook: AddressBook,
|
||||||
|
|
@ -58,24 +60,97 @@ class AddressBookCipher {
|
||||||
nonce = nonce.toHexString().lowercase(),
|
nonce = nonce.toHexString().lowercase(),
|
||||||
ciphertext = ciphertext.toHexString().lowercase(),
|
ciphertext = ciphertext.toHexString().lowercase(),
|
||||||
authTag = authTag.toHexString().lowercase(),
|
authTag = authTag.toHexString().lowercase(),
|
||||||
)
|
).also { blob ->
|
||||||
|
// Metadata only — the plaintext (contact names/addresses/memos) is never logged.
|
||||||
|
logger.d(
|
||||||
|
"Encrypted address book for wallet ${blob.walletId}: contacts=${addressBook.contacts.size}, " +
|
||||||
|
"plaintextBytes=${plaintext.size}, version=${blob.version}, " +
|
||||||
|
"nonceLen=${blob.nonce.length}, ciphertextLen=${blob.ciphertext.length}, " +
|
||||||
|
"authTagLen=${blob.authTag.length}",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun decrypt(blob: AddressBookBlob, userWallet: UserWallet): Either<AddressBookCryptoError, AddressBook> = either {
|
fun decrypt(blob: AddressBookBlob, userWallet: UserWallet): Either<AddressBookCryptoError, AddressBook> = either {
|
||||||
ensure(blob.walletId == userWallet.walletId.stringValue) { AddressBookCryptoError.WalletMismatch }
|
// Metadata only — helps QA correlate a failing blob with what was received from the backend/other platform.
|
||||||
|
logger.d(
|
||||||
|
"Decrypting address book for wallet ${blob.walletId}: version=${blob.version}, " +
|
||||||
|
"updatedAt=${blob.updatedAt}, nonceLen=${blob.nonce.length}, " +
|
||||||
|
"ciphertextLen=${blob.ciphertext.length}, authTagLen=${blob.authTag.length}",
|
||||||
|
)
|
||||||
|
|
||||||
|
ensure(blob.walletId == userWallet.walletId.stringValue) {
|
||||||
|
logger.w(
|
||||||
|
"Wallet mismatch decrypting address book: blob wallet=${blob.walletId}, " +
|
||||||
|
"target wallet=${userWallet.walletId.stringValue}",
|
||||||
|
)
|
||||||
|
AddressBookCryptoError.WalletMismatch
|
||||||
|
}
|
||||||
|
|
||||||
val aesKey = deriveKey(userWallet)
|
val aesKey = deriveKey(userWallet)
|
||||||
val nonce = blob.nonce.hexToBytesOrNull() ?: raise(AddressBookCryptoError.DecryptionFailed)
|
val nonce = blob.nonce.hexToBytesOrNull() ?: raiseNonHex(blob, field = "nonce", value = blob.nonce)
|
||||||
val ciphertext = blob.ciphertext.hexToBytesOrNull() ?: raise(AddressBookCryptoError.DecryptionFailed)
|
val ciphertext = blob.ciphertext.hexToBytesOrNull() ?: raiseNonHex(
|
||||||
val authTag = blob.authTag.hexToBytesOrNull() ?: raise(AddressBookCryptoError.DecryptionFailed)
|
blob = blob,
|
||||||
|
field = "ciphertext",
|
||||||
|
value = blob.ciphertext,
|
||||||
|
)
|
||||||
|
val authTag = blob.authTag.hexToBytesOrNull() ?: raiseNonHex(blob, field = "authTag", value = blob.authTag)
|
||||||
|
|
||||||
val plaintext = runCatching {
|
val plaintext = runCatching {
|
||||||
cipher(Cipher.DECRYPT_MODE, aesKey, nonce).doFinal(ciphertext + authTag)
|
cipher(Cipher.DECRYPT_MODE, aesKey, nonce).doFinal(ciphertext + authTag)
|
||||||
}.getOrElse { raise(AddressBookCryptoError.DecryptionFailed) }
|
}.getOrElse { error ->
|
||||||
|
logger.e(
|
||||||
|
"Failed to AES-GCM decrypt address book for wallet ${blob.walletId} " +
|
||||||
|
"(nonceBytes=${nonce.size}, ciphertextBytes=${ciphertext.size}, authTagBytes=${authTag.size}): " +
|
||||||
|
"${error.safeDescription()}. Usually a wrong key/nonce/tag or a corrupted blob.",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
raise(AddressBookCryptoError.DecryptionFailed)
|
||||||
|
}
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
json.decodeFromString(AddressBook.serializer(), plaintext.toString(Charsets.UTF_8))
|
json.decodeFromString(AddressBook.serializer(), plaintext.toString(Charsets.UTF_8))
|
||||||
}.getOrElse { raise(AddressBookCryptoError.MalformedBlob) }
|
}.getOrElse { error ->
|
||||||
|
// Decryption succeeded, so this is a payload schema/format mismatch — the prime suspect for a book
|
||||||
|
|
||||||
|
// reason (missing/extra field, wrong type, JSON path) but strips any raw plaintext the exception echoes.
|
||||||
|
logger.e(
|
||||||
|
"Failed to parse decrypted address book for wallet ${blob.walletId} " +
|
||||||
|
"(plaintextBytes=${plaintext.size}, version=${blob.version}): ${error.safeDescription()}. " +
|
||||||
|
"Decryption OK → cross-platform payload schema/format mismatch.",
|
||||||
|
)
|
||||||
|
raise(AddressBookCryptoError.MalformedBlob)
|
||||||
|
}.also { addressBook ->
|
||||||
|
logger.d(
|
||||||
|
messageString = "Decrypted address book for wallet ${blob.walletId}: " +
|
||||||
|
"contacts=${addressBook.contacts.size}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Raise<AddressBookCryptoError>.raiseNonHex(
|
||||||
|
blob: AddressBookBlob,
|
||||||
|
field: String,
|
||||||
|
value: String,
|
||||||
|
): Nothing {
|
||||||
|
logger.e(
|
||||||
|
"Address book blob has non-hex $field for wallet ${blob.walletId} " +
|
||||||
|
"(${field}Len=${value.length}, version=${blob.version}).",
|
||||||
|
)
|
||||||
|
raise(AddressBookCryptoError.DecryptionFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A log-safe, one-line description of a failure: exception type + message, with any raw decrypted payload
|
||||||
|
* stripped. kotlinx.serialization appends the offending input after a `JSON input:` marker; everything before
|
||||||
|
* it (the reason and the `at path: …` location) is structural and safe to log.
|
||||||
|
*/
|
||||||
|
private fun Throwable.safeDescription(): String {
|
||||||
|
val safeMessage = message.orEmpty().substringBefore("JSON input:").trim()
|
||||||
|
return buildString {
|
||||||
|
append(this@safeDescription::class.simpleName)
|
||||||
|
if (safeMessage.isNotEmpty()) append(": ").append(safeMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Raise<AddressBookCryptoError>.deriveKey(userWallet: UserWallet): ByteArray {
|
private fun Raise<AddressBookCryptoError>.deriveKey(userWallet: UserWallet): ByteArray {
|
||||||
|
|
@ -91,6 +166,7 @@ class AddressBookCipher {
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
const val LOG_TAG = "AddressBook"
|
||||||
const val AES_GCM_TRANSFORMATION = "AES/GCM/NoPadding"
|
const val AES_GCM_TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
const val AES_ALGORITHM = "AES"
|
const val AES_ALGORITHM = "AES"
|
||||||
const val NONCE_SIZE_BYTES = 12
|
const val NONCE_SIZE_BYTES = 12
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import com.tangem.domain.models.network.Network
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/** A single saved address belonging to a [Contact]. */
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AddressEntry(
|
data class AddressEntry(
|
||||||
@SerialName("id")
|
@SerialName("id")
|
||||||
|
|
@ -15,10 +14,8 @@ data class AddressEntry(
|
||||||
@SerialName("networkId")
|
@SerialName("networkId")
|
||||||
@Serializable(with = NetworkRawIdAsStringSerializer::class)
|
@Serializable(with = NetworkRawIdAsStringSerializer::class)
|
||||||
val networkId: Network.RawID,
|
val networkId: Network.RawID,
|
||||||
@SerialName("networkName")
|
|
||||||
val networkName: String,
|
|
||||||
@SerialName("memo")
|
@SerialName("memo")
|
||||||
val memo: String?,
|
val memo: String? = null,
|
||||||
@SerialName("signature")
|
@SerialName("signature")
|
||||||
val signature: String,
|
val signature: String,
|
||||||
)
|
)
|
||||||
|
|
@ -266,7 +266,6 @@ internal class AddressBookCipherTest {
|
||||||
networkId = Network.RawID("ethereum"),
|
networkId = Network.RawID("ethereum"),
|
||||||
memo = memo,
|
memo = memo,
|
||||||
signature = "",
|
signature = "",
|
||||||
networkName = "Ethereum",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun String.flipFirstHexNibble(): String = (if (first() == '0') '1' else '0') + substring(1)
|
private fun String.flipFirstHexNibble(): String = (if (first() == '0') '1' else '0') + substring(1)
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@ class GetVerifiedContactsInteractorTest {
|
||||||
id = AddressEntryId("addr-1"),
|
id = AddressEntryId("addr-1"),
|
||||||
address = "0xabc",
|
address = "0xabc",
|
||||||
networkId = Network.RawID("ethereum"),
|
networkId = Network.RawID("ethereum"),
|
||||||
networkName = "Ethereum",
|
|
||||||
memo = null,
|
memo = null,
|
||||||
signature = "AABB",
|
signature = "AABB",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,6 @@ internal class SaveContactInteractorTest {
|
||||||
networkId = networkRawId,
|
networkId = networkRawId,
|
||||||
memo = memo,
|
memo = memo,
|
||||||
signature = "sig",
|
signature = "sig",
|
||||||
networkName = "Ethereum",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray {
|
private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ internal class AddressBookSerializationTest {
|
||||||
id = AddressEntryId("addr-1"),
|
id = AddressEntryId("addr-1"),
|
||||||
address = "0xabc",
|
address = "0xabc",
|
||||||
networkId = Network.RawID("ethereum"),
|
networkId = Network.RawID("ethereum"),
|
||||||
networkName = "Ethereum",
|
|
||||||
memo = null,
|
memo = null,
|
||||||
signature = "",
|
signature = "",
|
||||||
),
|
),
|
||||||
|
|
@ -71,7 +70,6 @@ internal class AddressBookSerializationTest {
|
||||||
id = AddressEntryId("addr-1"),
|
id = AddressEntryId("addr-1"),
|
||||||
address = "0xabc",
|
address = "0xabc",
|
||||||
networkId = Network.RawID("ethereum"),
|
networkId = Network.RawID("ethereum"),
|
||||||
networkName = "Ethereum",
|
|
||||||
memo = "memo",
|
memo = "memo",
|
||||||
signature = "sig",
|
signature = "sig",
|
||||||
),
|
),
|
||||||
|
|
@ -102,4 +100,23 @@ internal class AddressBookSerializationTest {
|
||||||
// Assert
|
// Assert
|
||||||
assertThat(error).isNotNull()
|
assertThat(error).isNotNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN entry with null memo WHEN serialized THEN memo key is omitted`() {
|
||||||
|
// Arrange — kotlinx omits properties equal to their default, so a null memo must not appear in the JSON,
|
||||||
|
// matching iOS's encodeIfPresent for String? optionals.
|
||||||
|
val entry = AddressEntry(
|
||||||
|
id = AddressEntryId("a1"),
|
||||||
|
address = "0xabc",
|
||||||
|
networkId = Network.RawID("ethereum"),
|
||||||
|
memo = null,
|
||||||
|
signature = "sig",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val obj = json.parseToJsonElement(json.encodeToString(AddressEntry.serializer(), entry)).jsonObject
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertThat(obj.keys).doesNotContain("memo")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +90,6 @@ class CheckAddressDuplicateUseCaseTest {
|
||||||
networkId = Network.RawID(networkId),
|
networkId = Network.RawID(networkId),
|
||||||
memo = null,
|
memo = null,
|
||||||
signature = "sig",
|
signature = "sig",
|
||||||
networkName = "Ethereum",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,6 @@ class GetContactsUseCaseTest {
|
||||||
networkId = Network.RawID("ethereum"),
|
networkId = Network.RawID("ethereum"),
|
||||||
memo = null,
|
memo = null,
|
||||||
signature = "sig",
|
signature = "sig",
|
||||||
networkName = "Ethereum",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,6 @@ class ContactNameValidatorTest {
|
||||||
networkId = Network.RawID("ethereum"),
|
networkId = Network.RawID("ethereum"),
|
||||||
memo = null,
|
memo = null,
|
||||||
signature = "AABB",
|
signature = "AABB",
|
||||||
networkName = "Ethereum",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,6 @@ class ContactSignatureVerifierTest {
|
||||||
id = AddressEntryId(id),
|
id = AddressEntryId(id),
|
||||||
address = address,
|
address = address,
|
||||||
networkId = Network.RawID("ethereum"),
|
networkId = Network.RawID("ethereum"),
|
||||||
networkName = "Ethereum",
|
|
||||||
memo = memo,
|
memo = memo,
|
||||||
signature = signature,
|
signature = signature,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package com.tangem.features.addressbook.common
|
package com.tangem.features.addressbook.common
|
||||||
|
|
||||||
|
import com.tangem.blockchain.common.Blockchain
|
||||||
|
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||||
import com.tangem.common.ui.account.AccountIconUM
|
import com.tangem.common.ui.account.AccountIconUM
|
||||||
|
import com.tangem.domain.addressbook.model.AddressEntry
|
||||||
import com.tangem.domain.addressbook.model.Contact
|
import com.tangem.domain.addressbook.model.Contact
|
||||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||||
import com.tangem.features.addressbook.MatchedContact
|
import com.tangem.features.addressbook.MatchedContact
|
||||||
|
|
@ -27,7 +30,7 @@ internal object ContactMatcher {
|
||||||
MatchedContact.ContactAddress(
|
MatchedContact.ContactAddress(
|
||||||
address = entry.address,
|
address = entry.address,
|
||||||
memo = entry.memo,
|
memo = entry.memo,
|
||||||
networkName = entry.networkName,
|
networkName = entry.displayNetworkName(),
|
||||||
)
|
)
|
||||||
}.toImmutableList(),
|
}.toImmutableList(),
|
||||||
)
|
)
|
||||||
|
|
@ -40,4 +43,8 @@ internal object ContactMatcher {
|
||||||
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor }
|
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor }
|
||||||
?: CryptoPortfolioIcon.Color.Azure,
|
?: CryptoPortfolioIcon.Color.Azure,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Display network name is derived from [AddressEntry.networkId] — it is not stored in the encrypted payload. */
|
||||||
|
private fun AddressEntry.displayNetworkName(): String =
|
||||||
|
Blockchain.fromNetworkId(networkId.value)?.fullName ?: networkId.value
|
||||||
}
|
}
|
||||||
|
|
@ -37,7 +37,6 @@ internal class ContactAddressEntriesConverter {
|
||||||
id = AddressEntryId(UUID.randomUUID().toString()),
|
id = AddressEntryId(UUID.randomUUID().toString()),
|
||||||
address = address,
|
address = address,
|
||||||
networkId = Network.RawID(rawId),
|
networkId = Network.RawID(rawId),
|
||||||
networkName = blockchain?.fullName ?: rawId,
|
|
||||||
memo = memo?.takeIf { hasExtrasSupport },
|
memo = memo?.takeIf { hasExtrasSupport },
|
||||||
signature = "",
|
signature = "",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ internal class DefaultAddressBookListComponent(
|
||||||
is AddressBookListUM.Content -> AddressBookListScreen(
|
is AddressBookListUM.Content -> AddressBookListScreen(
|
||||||
state = addressBookListUM,
|
state = addressBookListUM,
|
||||||
onBackClick = router::pop,
|
onBackClick = router::pop,
|
||||||
modifier = modifier.background(TangemTheme.colors3.bg.primary),
|
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
selector.child?.instance?.BottomSheet()
|
selector.child?.instance?.BottomSheet()
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ internal fun AddressBookListScreen(
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
|
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
TangemTopBar(
|
TangemTopBar(
|
||||||
modifier = Modifier.statusBarsPadding(),
|
modifier = Modifier.statusBarsPadding(),
|
||||||
title = resourceReference(R.string.address_book_title),
|
title = resourceReference(R.string.address_book_title),
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,6 @@ internal class ContactMatcherTest {
|
||||||
networkId = Network.RawID(networkId),
|
networkId = Network.RawID(networkId),
|
||||||
memo = memo,
|
memo = memo,
|
||||||
signature = "sig",
|
signature = "sig",
|
||||||
networkName = "Ethereum",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|
|
||||||
|
|
@ -808,7 +808,6 @@ internal class EditContactModelTest {
|
||||||
id = AddressEntryId("e-1"),
|
id = AddressEntryId("e-1"),
|
||||||
address = "0xAAA",
|
address = "0xAAA",
|
||||||
networkId = Network.RawID("ethereum"),
|
networkId = Network.RawID("ethereum"),
|
||||||
networkName = "Ethereum",
|
|
||||||
memo = null,
|
memo = null,
|
||||||
signature = "sig",
|
signature = "sig",
|
||||||
),
|
),
|
||||||
|
|
@ -816,7 +815,6 @@ internal class EditContactModelTest {
|
||||||
id = AddressEntryId("e-2"),
|
id = AddressEntryId("e-2"),
|
||||||
address = "0xBBB",
|
address = "0xBBB",
|
||||||
networkId = Network.RawID("bsc"),
|
networkId = Network.RawID("bsc"),
|
||||||
networkName = "BSC",
|
|
||||||
memo = null,
|
memo = null,
|
||||||
signature = "sig",
|
signature = "sig",
|
||||||
),
|
),
|
||||||
|
|
@ -1016,7 +1014,6 @@ internal class EditContactModelTest {
|
||||||
id = AddressEntryId("e-1"),
|
id = AddressEntryId("e-1"),
|
||||||
address = address,
|
address = address,
|
||||||
networkId = Network.RawID("ethereum"),
|
networkId = Network.RawID("ethereum"),
|
||||||
networkName = "Ethereum",
|
|
||||||
memo = null,
|
memo = null,
|
||||||
signature = "sig",
|
signature = "sig",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,6 @@ internal class AddressBookListModelTest {
|
||||||
id = AddressEntryId("e-$id"),
|
id = AddressEntryId("e-$id"),
|
||||||
address = "0xABC",
|
address = "0xABC",
|
||||||
networkId = Network.RawID("ethereum"),
|
networkId = Network.RawID("ethereum"),
|
||||||
networkName = "Ethereum",
|
|
||||||
memo = null,
|
memo = null,
|
||||||
signature = "sig",
|
signature = "sig",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,6 @@ internal class UpdateAddressBookListContentTransformerTest {
|
||||||
networkId = Network.RawID("ethereum"),
|
networkId = Network.RawID("ethereum"),
|
||||||
memo = null,
|
memo = null,
|
||||||
signature = "sig",
|
signature = "sig",
|
||||||
networkName = "Ethereum",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -589,7 +589,6 @@ internal class SendDestinationModelTest {
|
||||||
id = AddressEntryId("e1"),
|
id = AddressEntryId("e1"),
|
||||||
address = address,
|
address = address,
|
||||||
networkId = Network.RawID(networkRawId),
|
networkId = Network.RawID(networkRawId),
|
||||||
networkName = "Ethereum",
|
|
||||||
memo = null,
|
memo = null,
|
||||||
signature = "",
|
signature = "",
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue