Updated on 2026-08-14
This commit is contained in:
parent
3e47825909
commit
1f00384c0c
22 changed files with 59 additions and 63 deletions
|
|
@ -8,7 +8,6 @@ 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
|
||||
|
|
@ -391,7 +390,7 @@ internal class DefaultAddressBookRepositoryTest {
|
|||
iconColor = iconColor,
|
||||
createdAt = TIMESTAMP,
|
||||
updatedAt = TIMESTAMP,
|
||||
addressEntries = emptyList(),
|
||||
addresses = emptyList(),
|
||||
)
|
||||
|
||||
private fun createBlob(): AddressBookBlob = AddressBookBlob(
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class GetVerifiedContactsInteractor(
|
|||
val userWallet = walletsById[contact.walletId] ?: return@mapNotNull null
|
||||
val verification = verify(userWallet, contact).getOrNull() ?: return@mapNotNull null
|
||||
VerifiedContact(
|
||||
contact = contact.copy(addressEntries = verification.valid),
|
||||
contact = contact.copy(addresses = verification.valid),
|
||||
invalidEntries = verification.invalid,
|
||||
)
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ class GetVerifiedContactsInteractor(
|
|||
userWallet: UserWallet,
|
||||
contact: Contact,
|
||||
): Either<VerifyMessagesError, AddressEntriesVerification> {
|
||||
val entries = contact.addressEntries
|
||||
val entries = contact.addresses
|
||||
if (entries.isEmpty()) return AddressEntriesVerification(valid = emptyList(), invalid = emptyList()).right()
|
||||
|
||||
// Entries with a malformed (non-hex) signature can't be verified — they are invalid by format.
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class SaveContactInteractor(
|
|||
userWallet: UserWallet,
|
||||
name: String,
|
||||
iconColor: String,
|
||||
addressEntries: List<AddressEntry>,
|
||||
addresses: List<AddressEntry>,
|
||||
): Either<SaveContactError, Contact> = either {
|
||||
val userWalletId = userWallet.walletId
|
||||
val validName = validateContactName(userWalletId, name)
|
||||
|
|
@ -47,9 +47,9 @@ class SaveContactInteractor(
|
|||
iconColor = iconColor,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
addressEntries = addressEntries,
|
||||
addresses = addresses,
|
||||
)
|
||||
val signed = signAddressEntries(userWallet, contact)
|
||||
val signed = signAddresses(userWallet, contact)
|
||||
.mapLeft(SaveContactError::Signing)
|
||||
.bind()
|
||||
repository.saveContact(signed)
|
||||
|
|
@ -63,7 +63,7 @@ class SaveContactInteractor(
|
|||
contact: Contact,
|
||||
name: String,
|
||||
iconColor: String,
|
||||
addressEntries: List<AddressEntry>,
|
||||
addresses: List<AddressEntry>,
|
||||
): Either<SaveContactError, Contact> = either {
|
||||
val validName = ContactName(name)
|
||||
.mapLeft { SaveContactError.Name(ContactNameValidationError.Format(it)) }
|
||||
|
|
@ -72,10 +72,10 @@ class SaveContactInteractor(
|
|||
val updated = contact.copy(
|
||||
name = validName,
|
||||
iconColor = iconColor,
|
||||
addressEntries = addressEntries,
|
||||
addresses = addresses,
|
||||
updatedAt = timestampProvider.now(),
|
||||
)
|
||||
val signed = signAddressEntries(userWallet, updated)
|
||||
val signed = signAddresses(userWallet, updated)
|
||||
.mapLeft(SaveContactError::Signing)
|
||||
.bind()
|
||||
repository.saveContact(signed)
|
||||
|
|
@ -84,11 +84,9 @@ class SaveContactInteractor(
|
|||
signed
|
||||
}
|
||||
|
||||
private suspend fun signAddressEntries(
|
||||
userWallet: UserWallet,
|
||||
contact: Contact,
|
||||
): Either<SignHashesError, Contact> = either {
|
||||
val entries = contact.addressEntries
|
||||
private suspend fun signAddresses(userWallet: UserWallet, contact: Contact): Either<SignHashesError, Contact> =
|
||||
either {
|
||||
val entries = contact.addresses
|
||||
if (entries.isEmpty()) return@either contact
|
||||
|
||||
val publicKey = userWallet.primarySecp256k1PublicKey() ?: raise(SignHashesError.NoSigningKey)
|
||||
|
|
@ -98,7 +96,7 @@ class SaveContactInteractor(
|
|||
val signedEntries = entries.mapIndexed { index, entry ->
|
||||
entry.copy(signature = signatures[index].toHexString())
|
||||
}
|
||||
contact.copy(addressEntries = signedEntries)
|
||||
contact.copy(addresses = signedEntries)
|
||||
}
|
||||
|
||||
private fun hashEntry(contact: Contact, entry: AddressEntry): ByteArray {
|
||||
|
|
|
|||
|
|
@ -19,5 +19,5 @@ data class Contact(
|
|||
val iconColor: String,
|
||||
val createdAt: String,
|
||||
val updatedAt: String,
|
||||
val addressEntries: List<AddressEntry>,
|
||||
val addresses: List<AddressEntry>,
|
||||
)
|
||||
|
|
@ -29,7 +29,7 @@ class CheckAddressDuplicateUseCase(
|
|||
val contacts = repository.getContactsSync(userWalletId)
|
||||
return contacts
|
||||
.firstOrNull { contact ->
|
||||
contact.id != excludeContactId && contact.addressEntries.any { entry ->
|
||||
contact.id != excludeContactId && contact.addresses.any { entry ->
|
||||
entry.networkId.value == networkId && entry.address == address
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class GetContactsUseCase(
|
|||
|
||||
private fun Contact.matches(query: String): Boolean {
|
||||
val isNameContaining = name.value.contains(other = query, ignoreCase = true)
|
||||
val isAddressContaining = addressEntries.any { addressEntry ->
|
||||
val isAddressContaining = addresses.any { addressEntry ->
|
||||
addressEntry.address.contains(other = query, ignoreCase = true)
|
||||
}
|
||||
return isNameContaining || isAddressContaining
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ internal class AddressBookCipherTest {
|
|||
iconColor = iconColor,
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-05-22T09:00:00.000Z",
|
||||
addressEntries = entries.toList(),
|
||||
addresses = entries.toList(),
|
||||
)
|
||||
|
||||
private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry(
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class GetVerifiedContactsInteractorTest {
|
|||
// Assert
|
||||
assertThat(result).containsExactly(
|
||||
VerifiedContact(
|
||||
contact = contact.copy(addressEntries = listOf(valid)),
|
||||
contact = contact.copy(addresses = listOf(valid)),
|
||||
invalidEntries = listOf(invalid),
|
||||
),
|
||||
)
|
||||
|
|
@ -93,8 +93,8 @@ class GetVerifiedContactsInteractorTest {
|
|||
// Assert
|
||||
assertThat(messagesSlot.captured.map { String(it) })
|
||||
.containsExactly(
|
||||
expectedPayload(contact, contact.addressEntries[0]),
|
||||
expectedPayload(contact, contact.addressEntries[1]),
|
||||
expectedPayload(contact, contact.addresses[0]),
|
||||
expectedPayload(contact, contact.addresses[1]),
|
||||
)
|
||||
.inOrder()
|
||||
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder()
|
||||
|
|
@ -114,7 +114,7 @@ class GetVerifiedContactsInteractorTest {
|
|||
val result = interactor(query = "").first().single()
|
||||
|
||||
// Assert
|
||||
assertThat(result.contact.addressEntries).containsExactly(valid1, valid2).inOrder()
|
||||
assertThat(result.contact.addresses).containsExactly(valid1, valid2).inOrder()
|
||||
assertThat(result.invalidEntries).containsExactly(invalid)
|
||||
}
|
||||
|
||||
|
|
@ -135,7 +135,7 @@ class GetVerifiedContactsInteractorTest {
|
|||
|
||||
// Assert
|
||||
assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB")
|
||||
assertThat(result.contact.addressEntries).containsExactly(signed)
|
||||
assertThat(result.contact.addresses).containsExactly(signed)
|
||||
assertThat(result.invalidEntries).containsExactly(malformed)
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ class GetVerifiedContactsInteractorTest {
|
|||
val result = interactor(query = "").first().single()
|
||||
|
||||
// Assert
|
||||
assertThat(result.contact.addressEntries).isEmpty()
|
||||
assertThat(result.contact.addresses).isEmpty()
|
||||
assertThat(result.invalidEntries).isEmpty()
|
||||
verify(exactly = 0) { verifyMessages(any(), any(), any()) }
|
||||
}
|
||||
|
|
@ -192,7 +192,7 @@ class GetVerifiedContactsInteractorTest {
|
|||
iconColor = "KekColor",
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addressEntries = entries.toList(),
|
||||
addresses = entries.toList(),
|
||||
)
|
||||
|
||||
private fun entry(id: String, address: String, memo: String?, signature: String): AddressEntry = AddressEntry(
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ internal class SaveContactInteractorTest {
|
|||
assertThat(contact.id.value).isNotEmpty()
|
||||
assertThat(contact.createdAt).isEqualTo(NEW_TIMESTAMP)
|
||||
assertThat(contact.updatedAt).isEqualTo(NEW_TIMESTAMP)
|
||||
assertThat(contact.addressEntries.map { it.signature })
|
||||
assertThat(contact.addresses.map { it.signature })
|
||||
.containsExactly(signatures[0].toHexString())
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ internal class SaveContactInteractorTest {
|
|||
expectedHash(persisted, twoEntries[1]).toHexString(),
|
||||
)
|
||||
.inOrder()
|
||||
assertThat(persisted.addressEntries.map { it.signature })
|
||||
assertThat(persisted.addresses.map { it.signature })
|
||||
.containsExactly(signatures[0].toHexString(), signatures[1].toHexString())
|
||||
.inOrder()
|
||||
}
|
||||
|
|
@ -138,7 +138,7 @@ internal class SaveContactInteractorTest {
|
|||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()).isEqualTo(saved.captured)
|
||||
assertThat(saved.captured.addressEntries).isEmpty()
|
||||
assertThat(saved.captured.addresses).isEmpty()
|
||||
coVerify(exactly = 0) { signUseCase(any<List<ByteArray>>(), any(), any()) }
|
||||
}
|
||||
|
||||
|
|
@ -249,7 +249,7 @@ internal class SaveContactInteractorTest {
|
|||
contact = existing,
|
||||
name = "Bob",
|
||||
iconColor = "TestColor",
|
||||
addressEntries = updatedEntries,
|
||||
addresses = updatedEntries,
|
||||
)
|
||||
|
||||
// Assert
|
||||
|
|
@ -259,7 +259,7 @@ internal class SaveContactInteractorTest {
|
|||
assertThat(contact.name.value).isEqualTo("Bob")
|
||||
assertThat(contact.createdAt).isEqualTo(ORIGINAL_TIMESTAMP)
|
||||
assertThat(contact.updatedAt).isEqualTo(NEW_TIMESTAMP)
|
||||
assertThat(contact.addressEntries.map { it.signature })
|
||||
assertThat(contact.addresses.map { it.signature })
|
||||
.containsExactly(signatures[0].toHexString())
|
||||
coVerify(exactly = 0) { repository.getContacts(any<UserWalletId>()) }
|
||||
}
|
||||
|
|
@ -276,7 +276,7 @@ internal class SaveContactInteractorTest {
|
|||
contact = contact(name = "Alice"),
|
||||
name = "Bob",
|
||||
iconColor = "TestColor",
|
||||
addressEntries = updatedEntries,
|
||||
addresses = updatedEntries,
|
||||
)
|
||||
|
||||
// Assert
|
||||
|
|
@ -292,7 +292,7 @@ internal class SaveContactInteractorTest {
|
|||
contact = contact(name = "Alice"),
|
||||
name = "",
|
||||
iconColor = "TestColor",
|
||||
addressEntries = updatedEntries,
|
||||
addresses = updatedEntries,
|
||||
)
|
||||
|
||||
// Assert
|
||||
|
|
@ -310,7 +310,7 @@ internal class SaveContactInteractorTest {
|
|||
iconColor = "TestColor",
|
||||
createdAt = ORIGINAL_TIMESTAMP,
|
||||
updatedAt = ORIGINAL_TIMESTAMP,
|
||||
addressEntries = listOf(entry(id = "addr-$name", address = "0xabc", memo = null)),
|
||||
addresses = listOf(entry(id = "addr-$name", address = "0xabc", memo = null)),
|
||||
)
|
||||
|
||||
private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry(
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ class CheckAddressDuplicateUseCaseTest {
|
|||
iconColor = "Azure",
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addressEntries = listOf(
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = address,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,6 @@ class GetContactByIdUseCaseTest {
|
|||
iconColor = "Azure",
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addressEntries = emptyList(),
|
||||
addresses = emptyList(),
|
||||
)
|
||||
}
|
||||
|
|
@ -112,7 +112,7 @@ class GetContactsUseCaseTest {
|
|||
iconColor = "KekColor",
|
||||
createdAt = createdAt,
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addressEntries = listOf(
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = address,
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ class ValidateContactNameUseCaseTest {
|
|||
iconColor = "KekColor",
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addressEntries = listOf(
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = "0xabc",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ internal object ContactMatcher {
|
|||
|
||||
fun match(contacts: List<Contact>, networkId: String): List<MatchedContact> {
|
||||
return contacts.mapNotNull { contact ->
|
||||
val entries = contact.addressEntries.filter { it.networkId.value == networkId }
|
||||
val entries = contact.addresses.filter { it.networkId.value == networkId }
|
||||
if (entries.isEmpty()) return@mapNotNull null
|
||||
|
||||
MatchedContact(
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ internal class EditContactModel @Inject constructor(
|
|||
stateController.update(UpdateContactNameTransformer(name = contact.name.value))
|
||||
CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == contact.iconColor }
|
||||
?.let { stateController.update(SelectContactColorTransformer(color = it)) }
|
||||
val addresses = ContactAddressEntriesConverter().toValidatedAddresses(contact.addressEntries)
|
||||
val addresses = ContactAddressEntriesConverter().toValidatedAddresses(contact.addresses)
|
||||
stateController.update(SetValidatedAddressesTransformer(addresses = addresses, maxAddresses = MAX_ADDRESSES))
|
||||
}
|
||||
|
||||
|
|
@ -298,7 +298,7 @@ internal class EditContactModel @Inject constructor(
|
|||
if (saveJob?.isActive == true) return
|
||||
val userWallet = selectedWallet.value ?: return
|
||||
val ui = stateController.uiState.value
|
||||
val addressEntries = ContactAddressEntriesConverter().convert(ui.addresses)
|
||||
val addresses = ContactAddressEntriesConverter().convert(ui.addresses)
|
||||
val existing = loadedContact.value
|
||||
|
||||
saveJob = modelScope.launch {
|
||||
|
|
@ -308,14 +308,14 @@ internal class EditContactModel @Inject constructor(
|
|||
contact = existing,
|
||||
name = ui.name,
|
||||
iconColor = ui.colors.selected.name,
|
||||
addressEntries = addressEntries,
|
||||
addresses = addresses,
|
||||
)
|
||||
} else {
|
||||
saveContactInteractor.createContact(
|
||||
userWallet = userWallet,
|
||||
name = ui.name,
|
||||
iconColor = ui.colors.selected.name,
|
||||
addressEntries = addressEntries,
|
||||
addresses = addresses,
|
||||
)
|
||||
}
|
||||
result.fold(
|
||||
|
|
@ -514,7 +514,7 @@ internal class EditContactModel @Inject constructor(
|
|||
private fun Contact.toSnapshot(): EditSnapshot = EditSnapshot(
|
||||
name = name.value,
|
||||
colorName = iconColor,
|
||||
addresses = ContactAddressEntriesConverter().toValidatedAddresses(addressEntries)
|
||||
addresses = ContactAddressEntriesConverter().toValidatedAddresses(addresses)
|
||||
.map { it.address to it.networkIds.toSet() },
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ internal class DefaultContactConverter(
|
|||
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == contact.iconColor }
|
||||
?: CryptoPortfolioIcon.Color.Azure,
|
||||
),
|
||||
networkAddressCount = contact.addressEntries.size,
|
||||
networkAddressCount = contact.addresses.size,
|
||||
onClick = { onContactClick(contact.id.value) },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ internal class ContactMatcherTest {
|
|||
iconColor = iconColor,
|
||||
createdAt = "2026-06-10T14:30:00.000Z",
|
||||
updatedAt = "2026-06-10T14:30:00.000Z",
|
||||
addressEntries = entries.toList(),
|
||||
addresses = entries.toList(),
|
||||
)
|
||||
|
||||
private fun entry(address: String, networkId: String, memo: String? = null): AddressEntry = AddressEntry(
|
||||
|
|
|
|||
|
|
@ -526,7 +526,7 @@ internal class EditContactModelTest {
|
|||
userWallet = walletA,
|
||||
name = "Satoshi",
|
||||
iconColor = any(),
|
||||
addressEntries = any(),
|
||||
addresses = any(),
|
||||
)
|
||||
}
|
||||
verify(exactly = 1) {
|
||||
|
|
@ -830,7 +830,7 @@ internal class EditContactModelTest {
|
|||
val walletA = createWallet(id = "aa", name = "Wallet A")
|
||||
setupWallets(wallets = listOf(walletA), selected = walletA)
|
||||
val contact = existingContact(walletId = "aa", name = "Alice", address = "0xAAA").copy(
|
||||
addressEntries = listOf(
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("e-1"),
|
||||
address = "0xAAA",
|
||||
|
|
@ -891,7 +891,7 @@ internal class EditContactModelTest {
|
|||
iconColor = CryptoPortfolioIcon.Color.Azure.name,
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addressEntries = listOf(
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("e-1"),
|
||||
address = address,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package com.tangem.features.addressbook.list.model
|
|||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
|
|
@ -129,7 +128,7 @@ internal class AddressBookListModelTest {
|
|||
iconColor = CryptoPortfolioIcon.Color.Azure.name,
|
||||
createdAt = TIMESTAMP,
|
||||
updatedAt = TIMESTAMP,
|
||||
addressEntries = listOf(
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("e-$id"),
|
||||
address = "0xABC",
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ internal class UpdateAddressBookListContentTransformerTest {
|
|||
iconColor = "Azure",
|
||||
createdAt = "2026-06-10T14:30:00.000Z",
|
||||
updatedAt = "2026-06-10T14:30:00.000Z",
|
||||
addressEntries = listOf(
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId(name),
|
||||
address = "addr-$name",
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ internal class SendDestinationModel @Inject constructor(
|
|||
if (address.contactName != null) return@combine false // sent via a contact
|
||||
val networkId = cryptoCurrency.network.rawId
|
||||
contactList.none { contact ->
|
||||
contact.addressEntries.any {
|
||||
contact.addresses.any {
|
||||
it.networkId.value == networkId && it.address.equals(address.actualAddress, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
|
@ -481,7 +481,7 @@ internal class SendDestinationModel @Inject constructor(
|
|||
private fun findContactByAddress(address: String): Contact? {
|
||||
val networkId = cryptoCurrency.network.rawId
|
||||
return contacts.value.firstOrNull { contact ->
|
||||
contact.addressEntries.any { entry ->
|
||||
contact.addresses.any { entry ->
|
||||
entry.networkId.value == networkId && entry.address.equals(address, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -489,7 +489,7 @@ internal class SendDestinationModelTest {
|
|||
iconColor = "#FFFFFF",
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addressEntries = listOf(
|
||||
addresses = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("e1"),
|
||||
address = address,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue