Canonical discoverability writing.
This commit is contained in:
parent
92f035bc2a
commit
b4aabd799b
|
@ -156,6 +156,7 @@ import org.whispersystems.textsecuregcm.storage.Accounts;
|
||||||
import org.whispersystems.textsecuregcm.storage.AccountsDynamoDb;
|
import org.whispersystems.textsecuregcm.storage.AccountsDynamoDb;
|
||||||
import org.whispersystems.textsecuregcm.storage.AccountsDynamoDbMigrator;
|
import org.whispersystems.textsecuregcm.storage.AccountsDynamoDbMigrator;
|
||||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||||
|
import org.whispersystems.textsecuregcm.storage.ContactDiscoveryWriter;
|
||||||
import org.whispersystems.textsecuregcm.storage.DeletedAccounts;
|
import org.whispersystems.textsecuregcm.storage.DeletedAccounts;
|
||||||
import org.whispersystems.textsecuregcm.storage.DeletedAccountsDirectoryReconciler;
|
import org.whispersystems.textsecuregcm.storage.DeletedAccountsDirectoryReconciler;
|
||||||
import org.whispersystems.textsecuregcm.storage.DeletedAccountsManager;
|
import org.whispersystems.textsecuregcm.storage.DeletedAccountsManager;
|
||||||
|
@ -482,6 +483,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
directoryServerConfiguration.getReplicationName(), directoryReconciliationClient);
|
directoryServerConfiguration.getReplicationName(), directoryReconciliationClient);
|
||||||
deletedAccountsDirectoryReconcilers.add(deletedAccountsDirectoryReconciler);
|
deletedAccountsDirectoryReconcilers.add(deletedAccountsDirectoryReconciler);
|
||||||
}
|
}
|
||||||
|
accountDatabaseCrawlerListeners.add(new ContactDiscoveryWriter(accounts));
|
||||||
// PushFeedbackProcessor may update device properties
|
// PushFeedbackProcessor may update device properties
|
||||||
accountDatabaseCrawlerListeners.add(new PushFeedbackProcessor(accountsManager));
|
accountDatabaseCrawlerListeners.add(new PushFeedbackProcessor(accountsManager));
|
||||||
// delete accounts last
|
// delete accounts last
|
||||||
|
|
|
@ -66,6 +66,9 @@ public class Account {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
private boolean stale;
|
private boolean stale;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
private boolean canonicallyDiscoverable;
|
||||||
|
|
||||||
public Account() {}
|
public Account() {}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
|
@ -93,6 +96,12 @@ public class Account {
|
||||||
this.number = number;
|
this.number = number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setCanonicallyDiscoverable(boolean canonicallyDiscoverable) {
|
||||||
|
requireNotStale();
|
||||||
|
|
||||||
|
this.canonicallyDiscoverable = canonicallyDiscoverable;
|
||||||
|
}
|
||||||
|
|
||||||
public String getNumber() {
|
public String getNumber() {
|
||||||
requireNotStale();
|
requireNotStale();
|
||||||
|
|
||||||
|
@ -228,6 +237,12 @@ public class Account {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isCanonicallyDiscoverable() {
|
||||||
|
requireNotStale();
|
||||||
|
|
||||||
|
return canonicallyDiscoverable;
|
||||||
|
}
|
||||||
|
|
||||||
public Optional<String> getRelay() {
|
public Optional<String> getRelay() {
|
||||||
requireNotStale();
|
requireNotStale();
|
||||||
|
|
||||||
|
|
|
@ -55,6 +55,8 @@ public class AccountsDynamoDb extends AbstractDynamoDbStore implements AccountSt
|
||||||
static final String ATTR_ACCOUNT_DATA = "D";
|
static final String ATTR_ACCOUNT_DATA = "D";
|
||||||
// internal version for optimistic locking
|
// internal version for optimistic locking
|
||||||
static final String ATTR_VERSION = "V";
|
static final String ATTR_VERSION = "V";
|
||||||
|
// canonically discoverable
|
||||||
|
static final String ATTR_CANONICALLY_DISCOVERABLE = "C";
|
||||||
|
|
||||||
private final DynamoDbClient client;
|
private final DynamoDbClient client;
|
||||||
private final DynamoDbAsyncClient asyncClient;
|
private final DynamoDbAsyncClient asyncClient;
|
||||||
|
@ -160,7 +162,8 @@ public class AccountsDynamoDb extends AbstractDynamoDbStore implements AccountSt
|
||||||
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid),
|
KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid),
|
||||||
ATTR_ACCOUNT_E164, AttributeValues.fromString(account.getNumber()),
|
ATTR_ACCOUNT_E164, AttributeValues.fromString(account.getNumber()),
|
||||||
ATTR_ACCOUNT_DATA, AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)),
|
ATTR_ACCOUNT_DATA, AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)),
|
||||||
ATTR_VERSION, AttributeValues.fromInt(account.getVersion())))
|
ATTR_VERSION, AttributeValues.fromInt(account.getVersion()),
|
||||||
|
ATTR_CANONICALLY_DISCOVERABLE, AttributeValues.fromBool(account.shouldBeVisibleInDirectory())))
|
||||||
.build())
|
.build())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
@ -193,13 +196,15 @@ public class AccountsDynamoDb extends AbstractDynamoDbStore implements AccountSt
|
||||||
updateItemRequest = UpdateItemRequest.builder()
|
updateItemRequest = UpdateItemRequest.builder()
|
||||||
.tableName(accountsTableName)
|
.tableName(accountsTableName)
|
||||||
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))
|
.key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))
|
||||||
.updateExpression("SET #data = :data ADD #version :version_increment")
|
.updateExpression("SET #data = :data, #cds = :cds ADD #version :version_increment")
|
||||||
.conditionExpression("attribute_exists(#number) AND #version = :version")
|
.conditionExpression("attribute_exists(#number) AND #version = :version")
|
||||||
.expressionAttributeNames(Map.of("#number", ATTR_ACCOUNT_E164,
|
.expressionAttributeNames(Map.of("#number", ATTR_ACCOUNT_E164,
|
||||||
"#data", ATTR_ACCOUNT_DATA,
|
"#data", ATTR_ACCOUNT_DATA,
|
||||||
|
"#cds", ATTR_CANONICALLY_DISCOVERABLE,
|
||||||
"#version", ATTR_VERSION))
|
"#version", ATTR_VERSION))
|
||||||
.expressionAttributeValues(Map.of(
|
.expressionAttributeValues(Map.of(
|
||||||
":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)),
|
":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)),
|
||||||
|
":cds", AttributeValues.fromBool(account.shouldBeVisibleInDirectory()),
|
||||||
":version", AttributeValues.fromInt(account.getVersion()),
|
":version", AttributeValues.fromInt(account.getVersion()),
|
||||||
":version_increment", AttributeValues.fromInt(1)))
|
":version_increment", AttributeValues.fromInt(1)))
|
||||||
.returnValues(ReturnValue.UPDATED_NEW)
|
.returnValues(ReturnValue.UPDATED_NEW)
|
||||||
|
@ -428,6 +433,7 @@ public class AccountsDynamoDb extends AbstractDynamoDbStore implements AccountSt
|
||||||
static Account fromItem(Map<String, AttributeValue> item) {
|
static Account fromItem(Map<String, AttributeValue> item) {
|
||||||
if (!item.containsKey(ATTR_ACCOUNT_DATA) ||
|
if (!item.containsKey(ATTR_ACCOUNT_DATA) ||
|
||||||
!item.containsKey(ATTR_ACCOUNT_E164) ||
|
!item.containsKey(ATTR_ACCOUNT_E164) ||
|
||||||
|
// TODO: eventually require ATTR_CANONICALLY_DISCOVERABLE
|
||||||
!item.containsKey(KEY_ACCOUNT_UUID)) {
|
!item.containsKey(KEY_ACCOUNT_UUID)) {
|
||||||
throw new RuntimeException("item missing values");
|
throw new RuntimeException("item missing values");
|
||||||
}
|
}
|
||||||
|
@ -436,6 +442,7 @@ public class AccountsDynamoDb extends AbstractDynamoDbStore implements AccountSt
|
||||||
account.setNumber(item.get(ATTR_ACCOUNT_E164).s());
|
account.setNumber(item.get(ATTR_ACCOUNT_E164).s());
|
||||||
account.setUuid(UUIDUtil.fromByteBuffer(item.get(KEY_ACCOUNT_UUID).b().asByteBuffer()));
|
account.setUuid(UUIDUtil.fromByteBuffer(item.get(KEY_ACCOUNT_UUID).b().asByteBuffer()));
|
||||||
account.setVersion(Integer.parseInt(item.get(ATTR_VERSION).n()));
|
account.setVersion(Integer.parseInt(item.get(ATTR_VERSION).n()));
|
||||||
|
account.setCanonicallyDiscoverable(Optional.ofNullable(item.get(ATTR_CANONICALLY_DISCOVERABLE)).map(av -> av.bool()).orElse(false));
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
package org.whispersystems.textsecuregcm.storage;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class ContactDiscoveryWriter extends AccountDatabaseCrawlerListener {
|
||||||
|
|
||||||
|
private final AccountStore accounts;
|
||||||
|
|
||||||
|
public ContactDiscoveryWriter(final AccountStore accounts) {
|
||||||
|
this.accounts = accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCrawlStart() {
|
||||||
|
// nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCrawlEnd(final Optional<UUID> fromUuid) {
|
||||||
|
// nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCrawlChunk(final Optional<UUID> fromUuid, final List<Account> chunkAccounts)
|
||||||
|
throws AccountDatabaseCrawlerRestartException {
|
||||||
|
for (Account account : chunkAccounts) {
|
||||||
|
if (account.isCanonicallyDiscoverable() != account.shouldBeVisibleInDirectory()) {
|
||||||
|
accounts.update(account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -23,6 +23,8 @@ public class AttributeValues {
|
||||||
return AttributeValue.builder().n(Long.toString(value)).build();
|
return AttributeValue.builder().n(Long.toString(value)).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static AttributeValue fromBool(boolean value) { return AttributeValue.builder().bool(value).build(); }
|
||||||
|
|
||||||
public static AttributeValue fromInt(int value) {
|
public static AttributeValue fromInt(int value) {
|
||||||
return AttributeValue.builder().n(Integer.toString(value)).build();
|
return AttributeValue.builder().n(Integer.toString(value)).build();
|
||||||
}
|
}
|
||||||
|
@ -43,6 +45,10 @@ public class AttributeValues {
|
||||||
return AttributeValue.builder().b(value).build();
|
return AttributeValue.builder().b(value).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean toBool(AttributeValue av) {
|
||||||
|
return av.bool();
|
||||||
|
}
|
||||||
|
|
||||||
private static int toInt(AttributeValue av) {
|
private static int toInt(AttributeValue av) {
|
||||||
return Integer.parseInt(av.n());
|
return Integer.parseInt(av.n());
|
||||||
}
|
}
|
||||||
|
@ -67,6 +73,10 @@ public class AttributeValues {
|
||||||
return Optional.ofNullable(item.get(key));
|
return Optional.ofNullable(item.get(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static boolean getBool(Map<String, AttributeValue> item, String key, boolean defaultValue) {
|
||||||
|
return AttributeValues.get(item, key).map(AttributeValues::toBool).orElse(defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
public static int getInt(Map<String, AttributeValue> item, String key, int defaultValue) {
|
public static int getInt(Map<String, AttributeValue> item, String key, int defaultValue) {
|
||||||
return AttributeValues.get(item, key).map(AttributeValues::toInt).orElse(defaultValue);
|
return AttributeValues.get(item, key).map(AttributeValues::toInt).orElse(defaultValue);
|
||||||
}
|
}
|
||||||
|
|
|
@ -143,11 +143,11 @@ class AccountsDynamoDbTest {
|
||||||
boolean freshUser = accountsDynamoDb.create(account);
|
boolean freshUser = accountsDynamoDb.create(account);
|
||||||
|
|
||||||
assertThat(freshUser).isTrue();
|
assertThat(freshUser).isTrue();
|
||||||
verifyStoredState("+14151112222", account.getUuid(), account);
|
verifyStoredState("+14151112222", account.getUuid(), account, true);
|
||||||
|
|
||||||
freshUser = accountsDynamoDb.create(account);
|
freshUser = accountsDynamoDb.create(account);
|
||||||
assertThat(freshUser).isTrue();
|
assertThat(freshUser).isTrue();
|
||||||
verifyStoredState("+14151112222", account.getUuid(), account);
|
verifyStoredState("+14151112222", account.getUuid(), account, true);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -161,7 +161,7 @@ class AccountsDynamoDbTest {
|
||||||
|
|
||||||
accountsDynamoDb.create(account);
|
accountsDynamoDb.create(account);
|
||||||
|
|
||||||
verifyStoredState("+14151112222", account.getUuid(), account);
|
verifyStoredState("+14151112222", account.getUuid(), account, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -210,7 +210,7 @@ class AccountsDynamoDbTest {
|
||||||
|
|
||||||
accountsDynamoDb.create(account);
|
accountsDynamoDb.create(account);
|
||||||
|
|
||||||
verifyStoredState("+14151112222", account.getUuid(), account);
|
verifyStoredState("+14151112222", account.getUuid(), account, true);
|
||||||
|
|
||||||
account.setProfileName("name");
|
account.setProfileName("name");
|
||||||
|
|
||||||
|
@ -223,7 +223,7 @@ class AccountsDynamoDbTest {
|
||||||
|
|
||||||
final boolean freshUser = accountsDynamoDb.create(account);
|
final boolean freshUser = accountsDynamoDb.create(account);
|
||||||
assertThat(freshUser).isFalse();
|
assertThat(freshUser).isFalse();
|
||||||
verifyStoredState("+14151112222", firstUuid, account);
|
verifyStoredState("+14151112222", firstUuid, account, true);
|
||||||
|
|
||||||
device = generateDevice(1);
|
device = generateDevice(1);
|
||||||
Account invalidAccount = generateAccount("+14151113333", firstUuid, Collections.singleton(device));
|
Account invalidAccount = generateAccount("+14151113333", firstUuid, Collections.singleton(device));
|
||||||
|
@ -250,7 +250,7 @@ class AccountsDynamoDbTest {
|
||||||
retrieved = accountsDynamoDb.get(account.getUuid());
|
retrieved = accountsDynamoDb.get(account.getUuid());
|
||||||
|
|
||||||
assertThat(retrieved.isPresent()).isTrue();
|
assertThat(retrieved.isPresent()).isTrue();
|
||||||
verifyStoredState("+14151112222", account.getUuid(), account);
|
verifyStoredState("+14151112222", account.getUuid(), account, true);
|
||||||
|
|
||||||
device = generateDevice(1);
|
device = generateDevice(1);
|
||||||
Account unknownAccount = generateAccount("+14151113333", UUID.randomUUID(), Collections.singleton(device));
|
Account unknownAccount = generateAccount("+14151113333", UUID.randomUUID(), Collections.singleton(device));
|
||||||
|
@ -263,7 +263,7 @@ class AccountsDynamoDbTest {
|
||||||
|
|
||||||
assertThat(account.getVersion()).isEqualTo(2);
|
assertThat(account.getVersion()).isEqualTo(2);
|
||||||
|
|
||||||
verifyStoredState("+14151112222", account.getUuid(), account);
|
verifyStoredState("+14151112222", account.getUuid(), account, true);
|
||||||
|
|
||||||
account.setVersion(1);
|
account.setVersion(1);
|
||||||
|
|
||||||
|
@ -274,7 +274,7 @@ class AccountsDynamoDbTest {
|
||||||
|
|
||||||
accountsDynamoDb.update(account);
|
accountsDynamoDb.update(account);
|
||||||
|
|
||||||
verifyStoredState("+14151112222", account.getUuid(), account);
|
verifyStoredState("+14151112222", account.getUuid(), account, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -481,13 +481,13 @@ class AccountsDynamoDbTest {
|
||||||
|
|
||||||
assertThat(migrated).isTrue();
|
assertThat(migrated).isTrue();
|
||||||
|
|
||||||
verifyStoredState("+14151112222", account.getUuid(), account);
|
verifyStoredState("+14151112222", account.getUuid(), account, true);
|
||||||
|
|
||||||
migrated = accountsDynamoDb.migrate(account).get();
|
migrated = accountsDynamoDb.migrate(account).get();
|
||||||
|
|
||||||
assertThat(migrated).isFalse();
|
assertThat(migrated).isFalse();
|
||||||
|
|
||||||
verifyStoredState("+14151112222", account.getUuid(), account);
|
verifyStoredState("+14151112222", account.getUuid(), account, true);
|
||||||
|
|
||||||
UUID secondUuid = UUID.randomUUID();
|
UUID secondUuid = UUID.randomUUID();
|
||||||
|
|
||||||
|
@ -497,7 +497,7 @@ class AccountsDynamoDbTest {
|
||||||
migrated = accountsDynamoDb.migrate(accountRemigrationWithDifferentUuid).get();
|
migrated = accountsDynamoDb.migrate(accountRemigrationWithDifferentUuid).get();
|
||||||
|
|
||||||
assertThat(migrated).isFalse();
|
assertThat(migrated).isFalse();
|
||||||
verifyStoredState("+14151112222", firstUuid, account);
|
verifyStoredState("+14151112222", firstUuid, account, true);
|
||||||
|
|
||||||
account.setVersion(account.getVersion() + 1);
|
account.setVersion(account.getVersion() + 1);
|
||||||
|
|
||||||
|
@ -506,6 +506,39 @@ class AccountsDynamoDbTest {
|
||||||
assertThat(migrated).isTrue();
|
assertThat(migrated).isTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCanonicallyDiscoverableSet() {
|
||||||
|
Device device = generateDevice(1);
|
||||||
|
UUID uuid = UUID.randomUUID();
|
||||||
|
Account account = generateAccount("+14151112222", uuid, Collections.singleton(device));
|
||||||
|
account.setDiscoverableByPhoneNumber(false);
|
||||||
|
accountsDynamoDb.create(account);
|
||||||
|
verifyStoredState("+14151112222", account.getUuid(), account, false);
|
||||||
|
account.setDiscoverableByPhoneNumber(true);
|
||||||
|
accountsDynamoDb.update(account);
|
||||||
|
verifyStoredState("+14151112222", account.getUuid(), account, true);
|
||||||
|
account.setDiscoverableByPhoneNumber(false);
|
||||||
|
accountsDynamoDb.update(account);
|
||||||
|
verifyStoredState("+14151112222", account.getUuid(), account, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testContactDiscoveryWriter() throws Exception {
|
||||||
|
Device device = generateDevice(1);
|
||||||
|
UUID uuid = UUID.randomUUID();
|
||||||
|
Account account = generateAccount("+14151112222", uuid, Collections.singleton(device));
|
||||||
|
accountsDynamoDb.create(account);
|
||||||
|
verifyStoredState("+14151112222", account.getUuid(), account, true);
|
||||||
|
ContactDiscoveryWriter writer = new ContactDiscoveryWriter(accountsDynamoDb);
|
||||||
|
account.setCanonicallyDiscoverable(false);
|
||||||
|
writer.onCrawlChunk(null, List.of(account));
|
||||||
|
verifyStoredState("+14151112222", account.getUuid(), account, true);
|
||||||
|
account.setCanonicallyDiscoverable(true);
|
||||||
|
account.setDiscoverableByPhoneNumber(false);
|
||||||
|
writer.onCrawlChunk(null, List.of(account));
|
||||||
|
verifyStoredState("+14151112222", account.getUuid(), account, false);
|
||||||
|
}
|
||||||
|
|
||||||
private Device generateDevice(long id) {
|
private Device generateDevice(long id) {
|
||||||
Random random = new Random(System.currentTimeMillis());
|
Random random = new Random(System.currentTimeMillis());
|
||||||
SignedPreKey signedPreKey = new SignedPreKey(random.nextInt(), "testPublicKey-" + random.nextInt(), "testSignature-" + random.nextInt());
|
SignedPreKey signedPreKey = new SignedPreKey(random.nextInt(), "testPublicKey-" + random.nextInt(), "testSignature-" + random.nextInt());
|
||||||
|
@ -527,7 +560,7 @@ class AccountsDynamoDbTest {
|
||||||
return new Account(number, uuid, devices, unidentifiedAccessKey);
|
return new Account(number, uuid, devices, unidentifiedAccessKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void verifyStoredState(String number, UUID uuid, Account expecting) {
|
private void verifyStoredState(String number, UUID uuid, Account expecting, boolean canonicallyDiscoverable) {
|
||||||
final DynamoDbClient db = dynamoDbExtension.getDynamoDbClient();
|
final DynamoDbClient db = dynamoDbExtension.getDynamoDbClient();
|
||||||
|
|
||||||
final GetItemResponse get = db.getItem(GetItemRequest.builder()
|
final GetItemResponse get = db.getItem(GetItemRequest.builder()
|
||||||
|
@ -543,6 +576,8 @@ class AccountsDynamoDbTest {
|
||||||
assertThat(AttributeValues.getInt(get.item(), AccountsDynamoDb.ATTR_VERSION, -1))
|
assertThat(AttributeValues.getInt(get.item(), AccountsDynamoDb.ATTR_VERSION, -1))
|
||||||
.isEqualTo(expecting.getVersion());
|
.isEqualTo(expecting.getVersion());
|
||||||
|
|
||||||
|
assertThat(AttributeValues.getBool(get.item(), AccountsDynamoDb.ATTR_CANONICALLY_DISCOVERABLE, !canonicallyDiscoverable)).isEqualTo(canonicallyDiscoverable);
|
||||||
|
|
||||||
Account result = AccountsDynamoDb.fromItem(get.item());
|
Account result = AccountsDynamoDb.fromItem(get.item());
|
||||||
verifyStoredState(number, uuid, result, expecting);
|
verifyStoredState(number, uuid, result, expecting);
|
||||||
} else {
|
} else {
|
||||||
|
|
Loading…
Reference in New Issue