Merge branch 'master' into android-jni-classifier-consumer

This commit is contained in:
dvrdnz 2026-08-21 23:40:02 +02:00 committed by GitHub
commit 0e9e11d44f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 870 additions and 92 deletions

View File

@ -5,7 +5,7 @@ plugins {
application
eclipse
`check-lib-versions`
id("org.graalvm.buildtools.native") version "1.1.7"
id("org.graalvm.buildtools.native") version "1.1.9"
}
allprojects {

View File

@ -4,7 +4,7 @@ coroutines = "1.10.2"
junit = "6.1.2"
micronaut-json-schema = "2.1.0"
micronaut-core = "5.1.10"
signal-service = "2.15.3_unofficial_151"
signal-service = "2.15.3_unofficial_152"
[libraries]
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.85"

View File

@ -15,6 +15,7 @@ public record Contact(
long muteUntil,
boolean hideStory,
boolean isBlocked,
long blockedAt,
boolean isArchived,
boolean isProfileSharingEnabled,
boolean isHidden,
@ -34,6 +35,7 @@ public record Contact(
builder.muteUntil,
builder.hideStory,
builder.isBlocked,
builder.blockedAt,
builder.isArchived,
builder.isProfileSharingEnabled,
builder.isHidden,
@ -58,6 +60,7 @@ public record Contact(
builder.muteUntil = copy.muteUntil();
builder.hideStory = copy.hideStory();
builder.isBlocked = copy.isBlocked();
builder.blockedAt = copy.blockedAt();
builder.isArchived = copy.isArchived();
builder.isProfileSharingEnabled = copy.isProfileSharingEnabled();
builder.isHidden = copy.isHidden();
@ -109,6 +112,7 @@ public record Contact(
private long muteUntil;
private boolean hideStory;
private boolean isBlocked;
private long blockedAt;
private boolean isArchived;
private boolean isProfileSharingEnabled;
private boolean isHidden;
@ -177,10 +181,20 @@ public record Contact(
}
public Builder withIsBlocked(final boolean val) {
if (val && !isBlocked) {
blockedAt = System.currentTimeMillis();
} else if (!val) {
blockedAt = 0;
}
isBlocked = val;
return this;
}
public Builder withBlockedAt(final long val) {
blockedAt = val;
return this;
}
public Builder withIsArchived(final boolean val) {
isArchived = val;
return this;

View File

@ -739,7 +739,10 @@ public record MessageEnvelope(
null,
d.getE164(),
null))
.toList(), blockedListMessage.groupIds.stream().map(GroupId::unknownVersion).toList());
.toList(),
blockedListMessage.groups.stream()
.map(group -> GroupId.unknownVersion(group.getGroupId()))
.toList());
}
}

View File

@ -32,7 +32,13 @@ public class ServiceConfig {
final var attachmentBackfill = !isPrimaryDevice;
final var spqr = true;
final var usernameSyncChangeMessage = !isPrimaryDevice;
return new AccountAttributes.Capabilities(true, true, attachmentBackfill, spqr, usernameSyncChangeMessage);
final var optionalPhoneNumber = !isPrimaryDevice;
return new AccountAttributes.Capabilities(true,
true,
attachmentBackfill,
spqr,
usernameSyncChangeMessage,
optionalPhoneNumber);
}
public static ServiceEnvironmentConfig getServiceEnvironmentConfig(

View File

@ -50,6 +50,7 @@ import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevic
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
@ -399,13 +400,17 @@ public class AccountHelper {
}
private void reserveUsername(final List<Username> candidates) throws IOException {
final var candidateHashes = new ArrayList<String>();
final var candidateHashes = new ArrayList<byte[]>();
for (final var candidate : candidates) {
candidateHashes.add(Base64.encodeUrlSafeWithoutPadding(candidate.getHash()));
candidateHashes.add(candidate.getHash());
}
final var response = handleResponseException(dependencies.getAccountApi().reserveUsername(candidateHashes));
final var hashIndex = candidateHashes.indexOf(response.getUsernameHash());
final var usernameHash = handleResponseException(dependencies.getAccountApi().reserveUsername(candidateHashes));
final var hashIndex = candidateHashes.stream()
.filter(candidateHash -> Arrays.equals(candidateHash, usernameHash))
.findFirst()
.map(candidateHashes::indexOf)
.orElse(-1);
if (hashIndex == -1) {
logger.warn("[reserveUsername] The response hash could not be found in our set of candidateHashes.");
throw new IOException("Unexpected username response");
@ -498,8 +503,7 @@ public class AccountHelper {
final var usernameLink = account.getUsernameLink();
if (usernameLink == null) {
handleResponseException(dependencies.getAccountApi()
.reserveUsername(List.of(Base64.encodeUrlSafeWithoutPadding(username.getHash()))));
handleResponseException(dependencies.getAccountApi().reserveUsername(List.of(username.getHash())));
logger.debug("[reserveUsername] Successfully reserved existing username.");
final var linkComponents = confirmUsernameAndCreateNewLink(username);
account.setUsernameLink(linkComponents);
@ -534,19 +538,19 @@ public class AccountHelper {
public void setDeviceName(String deviceName) {
final var encryptedDeviceName = getEncryptedDeviceName(deviceName);
account.setEncryptedDeviceName(encryptedDeviceName);
account.setEncryptedDeviceName(Base64.encodeWithoutPadding(encryptedDeviceName));
}
public void setDeviceName(int deviceId, String deviceName) throws IOException {
final var encryptedDeviceName = getEncryptedDeviceName(deviceName);
handleResponseException(dependencies.getLinkDeviceApi().setDeviceName(encryptedDeviceName, deviceId));
handleResponseExceptionSuspend(cont -> dependencies.getLinkDeviceApi()
.setDeviceName(encryptedDeviceName, deviceId, cont));
context.getSyncHelper().sendDeviceNameChange(deviceId);
}
private String getEncryptedDeviceName(final String deviceName) {
private byte[] getEncryptedDeviceName(final String deviceName) {
final var identityKey = account.getAciIdentityKeyPair();
return Base64.encodeWithoutPadding(DeviceNameCipher.encryptDeviceName(deviceName.getBytes(StandardCharsets.UTF_8),
identityKey));
return DeviceNameCipher.encryptDeviceName(deviceName.getBytes(StandardCharsets.UTF_8), identityKey);
}
public void refreshDeviceName() throws IOException {
@ -585,7 +589,8 @@ public class AccountHelper {
account.getOrCreatePinMasterKey(),
account.getOrCreateMediaRootBackupKey(),
verificationCode.getVerificationCode(),
null));
null,
account.getAuthCredentialSalt()));
account.setMultiDevice(true);
context.getJobExecutor().enqueueJob(new SyncStorageJob());
}
@ -601,16 +606,14 @@ public class AccountHelper {
var masterKey = account.getOrCreatePinMasterKey();
context.getPinHelper().migrateRegistrationLockPin(account.getRegistrationLockPin(), masterKey);
handleResponseException(dependencies.getAccountApi()
.enableRegistrationLock(masterKey.deriveRegistrationLock()));
handleResponseException(dependencies.getAccountApi().enableRegistrationLock(masterKey));
}
public void setRegistrationPin(String pin) throws IOException {
var masterKey = account.getOrCreatePinMasterKey();
context.getPinHelper().setRegistrationLockPin(pin, masterKey);
handleResponseException(dependencies.getAccountApi()
.enableRegistrationLock(masterKey.deriveRegistrationLock()));
handleResponseException(dependencies.getAccountApi().enableRegistrationLock(masterKey));
account.setRegistrationLockPin(pin);
updateAccountAttributes();

View File

@ -85,12 +85,18 @@ public class ContactHelper {
}
public void setContactBlocked(RecipientId recipientId, boolean blocked) {
setContactBlocked(recipientId, blocked, blocked ? System.currentTimeMillis() : 0);
}
public void setContactBlocked(RecipientId recipientId, boolean blocked, long blockedAt) {
var contact = account.getContactStore().getContact(recipientId);
final var builder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact);
if (blocked) {
builder.withIsProfileSharingEnabled(false);
}
account.getContactStore().storeContact(recipientId, builder.withIsBlocked(blocked).build());
account.getContactStore()
.storeContact(recipientId,
builder.withIsBlocked(blocked).withBlockedAt(blocked ? blockedAt : 0).build());
}
public void setContactProfileSharing(RecipientId recipientId, boolean profileSharing) {

View File

@ -459,12 +459,21 @@ public class GroupHelper {
}
public void setGroupBlocked(final GroupId groupId, final boolean blocked) throws GroupNotFoundException {
setGroupBlocked(groupId, blocked, blocked ? System.currentTimeMillis() : 0);
}
public void setGroupBlocked(
final GroupId groupId,
final boolean blocked,
final long blockedAt
) throws GroupNotFoundException {
var group = getGroup(groupId);
if (group == null) {
throw new GroupNotFoundException(groupId);
}
group.setBlocked(blocked);
group.setBlockedAt(blocked ? blockedAt : 0);
account.getGroupStore().updateGroup(group);
context.getJobExecutor().enqueueJob(new SyncStorageJob());
}

View File

@ -626,13 +626,12 @@ public final class IncomingMessageHandler {
for (var individual : blockedListMessage.individuals) {
final var address = new RecipientAddress(individual.getAci(), individual.getE164());
final var recipientId = account.getRecipientResolver().resolveRecipient(address);
context.getContactHelper().setContactBlocked(recipientId, true);
context.getContactHelper().setContactBlocked(recipientId, true, individual.getBlockedAt());
}
for (var groupId : blockedListMessage.groupIds.stream()
.map(GroupId::unknownVersion)
.collect(Collectors.toSet())) {
for (var group : blockedListMessage.groups) {
final var groupId = GroupId.unknownVersion(group.getGroupId());
try {
context.getGroupHelper().setGroupBlocked(groupId, true);
context.getGroupHelper().setGroupBlocked(groupId, true, group.getBlockedAt());
} catch (GroupNotFoundException e) {
logger.warn("BlockedListMessage contained groupID that was not found in GroupStore: {}",
groupId.toBase64());

View File

@ -12,6 +12,7 @@ import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.signal.core.models.ServiceId.ACI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.EnvelopeResponse;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
@ -147,15 +148,19 @@ public class ReceiveHelper {
logger.debug("Retrieved {} envelopes!", batch.size());
isWaitingForMessage = false;
for (final var it : batch) {
SignalServiceEnvelope envelope1 = new SignalServiceEnvelope(it.getEnvelope(),
it.getServerDeliveredTimestamp());
final var sourceServiceId = envelope1.getSourceServiceId();
final var recipientId = sourceServiceId == null
? null
: account.getRecipientResolver().resolveRecipient(sourceServiceId);
logger.trace("Storing new message from {}", recipientId);
// store message on disk, before acknowledging receipt to the server
cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId);
if (it instanceof EnvelopeResponse.Unparseable) {
logger.warn("Received unparseable envelope from server, ignoring.");
} else if (it instanceof EnvelopeResponse.Parsed parsed) {
SignalServiceEnvelope envelope1 = new SignalServiceEnvelope(parsed.getEnvelope(),
parsed.getServerDeliveredTimestamp());
final var sourceServiceId = envelope1.getSourceServiceId();
final var recipientId = sourceServiceId == null
? null
: account.getRecipientResolver().resolveRecipient(sourceServiceId);
logger.trace("Storing new message from {}", recipientId);
// store message on disk, before acknowledging receipt to the server
cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId);
}
try {
signalWebSocket.sendAck(it);
} catch (IOException e) {
@ -167,6 +172,9 @@ public class ReceiveHelper {
backOffCounter = 0;
if (queueNotEmpty) {
if (cachedMessage[0] == null) {
continue;
}
if (remainingMessages > 0) {
remainingMessages -= 1;
}

View File

@ -14,6 +14,7 @@ import org.asamk.signal.manager.syncStorage.ContactRecordProcessor;
import org.asamk.signal.manager.syncStorage.GroupV1RecordProcessor;
import org.asamk.signal.manager.syncStorage.GroupV2RecordProcessor;
import org.asamk.signal.manager.syncStorage.StickerPackRecordProcessor;
import org.asamk.signal.manager.syncStorage.StorageSyncLoopDetector;
import org.asamk.signal.manager.syncStorage.StorageSyncModels;
import org.asamk.signal.manager.syncStorage.StorageSyncValidations;
import org.asamk.signal.manager.syncStorage.WriteOperationResult;
@ -61,11 +62,13 @@ public class StorageHelper {
private final SignalAccount account;
private final SignalDependencies dependencies;
private final Context context;
private final StorageSyncLoopDetector storageSyncLoopDetector;
public StorageHelper(final Context context) {
this.account = context.getAccount();
this.dependencies = context.getDependencies();
this.context = context;
this.storageSyncLoopDetector = new StorageSyncLoopDetector(account::isMultiDevice);
}
public void syncDataWithStorage() throws IOException {
@ -84,6 +87,7 @@ public class StorageHelper {
final var storageServiceRepository = dependencies.getStorageServiceRepository();
final var result = storageServiceRepository.getStorageManifestIfDifferentVersion(storageKey,
localManifestVersion);
final var fetchedRemoteManifest = result instanceof ManifestIfDifferentVersionResult.DifferentVersion;
var needsForcePush = false;
final var remoteManifest = switch (result) {
@ -146,7 +150,10 @@ public class StorageHelper {
needsForcePush = true;
} else {
try {
needsMultiDeviceSync = writeToStorage(storageKey, remoteManifest, needsForcePush);
needsMultiDeviceSync = writeToStorage(storageKey,
remoteManifest,
needsForcePush,
fetchedRemoteManifest);
} catch (RetryLaterException e) {
// TODO retry later
return;
@ -288,7 +295,8 @@ public class StorageHelper {
private boolean writeToStorage(
final StorageKey storageKey,
final SignalStorageManifest remoteManifest,
final boolean needsForcePush
final boolean needsForcePush,
final boolean fetchedRemoteManifest
) throws IOException, RetryLaterException {
final WriteOperationResult remoteWriteOperation;
try (final var connection = account.getAccountDatabase().getConnection()) {
@ -326,6 +334,19 @@ public class StorageHelper {
if (remoteWriteOperation.isEmpty()) {
logger.debug("No remote writes needed. Still at version: {}", remoteManifest.version);
storageSyncLoopDetector.onConverged();
return false;
}
final var loopCheck = storageSyncLoopDetector.onWriteAttempt(remoteWriteOperation,
fetchedRemoteManifest,
false);
if (loopCheck instanceof StorageSyncLoopDetector.Decision.Denied denied) {
logger.warn(
"Skipping remote write, another device is likely undoing it. Cause: {}, level: {}. WriteOperationResult :: {}",
denied.cause(),
denied.level(),
remoteWriteOperation);
return false;
}
@ -344,11 +365,18 @@ public class StorageHelper {
remoteWriteOperation.deletes());
switch (result) {
case WriteStorageRecordsResult.ConflictError ignored -> {
storageSyncLoopDetector.onWriteFailed();
logger.debug("Hit a conflict when trying to resolve the conflict! Retrying.");
throw new RetryLaterException();
}
case WriteStorageRecordsResult.NetworkError networkError -> throw networkError.getException();
case WriteStorageRecordsResult.StatusCodeError statusCodeError -> throw statusCodeError.getException();
case WriteStorageRecordsResult.NetworkError networkError -> {
storageSyncLoopDetector.onWriteFailed();
throw networkError.getException();
}
case WriteStorageRecordsResult.StatusCodeError statusCodeError -> {
storageSyncLoopDetector.onWriteFailed();
throw statusCodeError.getException();
}
case WriteStorageRecordsResult.Success ignored -> {
logger.debug("Saved new manifest. Now at version: {}", remoteWriteOperation.manifest().version);
storeManifestLocally(remoteWriteOperation.manifest());
@ -424,7 +452,7 @@ public class StorageHelper {
final var stickerPacks = account.getStickerStore()
.getStickerPacks(connection)
.stream()
.filter(pack -> pack.isInstalled() || pack.deletedTimestamp() > 0)
.filter(pack -> pack.storageId() != null)
.toList();
newStickerPackStorageIds = generateStickerPackStorageIds(stickerPacks);
for (final var stickerPack : stickerPacks) {
@ -688,6 +716,13 @@ public class StorageHelper {
final var contactRecordProcessor = new ContactRecordProcessor(account, connection, context.getJobExecutor());
final var stickerPackRecordProcessor = new StickerPackRecordProcessor(account, connection);
final var contactRecords = records.stream()
.filter(record -> record.getProto().contact != null)
.map(record -> StorageRecordConvertersKt.toSignalContactRecord(record.getProto().contact,
record.getId()))
.toList();
contactRecordProcessor.prepare(contactRecords);
for (final var record : records) {
if (record.getProto().account != null) {
logger.debug("Reading record {} of type account", record.getId());

View File

@ -237,18 +237,19 @@ public class SyncHelper {
final var address = account.getRecipientAddressResolver().resolveRecipientAddress(record.first());
if (address.aci().isPresent() || address.number().isPresent()) {
addresses.add(new BlockedListMessage.Individual(address.aci().orElse(null),
address.number().orElse(null)));
address.number().orElse(null),
record.second().blockedAt()));
}
}
}
var groupIds = new ArrayList<byte[]>();
var groups = new ArrayList<BlockedListMessage.Group>();
for (var record : account.getGroupStore().getGroups()) {
if (record.isBlocked()) {
groupIds.add(record.getGroupId().serialize());
groups.add(new BlockedListMessage.Group(record.getGroupId().serialize(), record.getBlockedAt()));
}
}
return context.getSendHelper()
.sendSyncMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(addresses, groupIds)));
.sendSyncMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(addresses, groups)));
}
public SendMessageResult sendVerifiedMessage(

View File

@ -214,6 +214,7 @@ public class ProvisioningManagerImpl implements ProvisioningManager, Closeable {
pniIdentity,
profileKey,
accountEntropyPool,
msg.authCredentialSalt == null ? null : msg.authCredentialSalt.toByteArray(),
mediaRootBackupKey);
if (msg.readReceipts != null) {

View File

@ -33,7 +33,7 @@ import java.util.UUID;
public class AccountDatabase extends Database {
private static final Logger logger = LoggerFactory.getLogger(AccountDatabase.class);
private static final long DATABASE_VERSION = 30;
private static final long DATABASE_VERSION = 31;
private AccountDatabase(final HikariDataSource dataSource) {
super(logger, DATABASE_VERSION, dataSource);
@ -643,6 +643,16 @@ public class AccountDatabase extends Database {
""");
}
}
if (oldVersion < 31) {
logger.debug("Updating database: Add blocked-at timestamps");
try (final var statement = connection.createStatement()) {
statement.executeUpdate("""
ALTER TABLE recipient ADD blocked_at INTEGER NOT NULL DEFAULT 0;
ALTER TABLE group_v1 ADD blocked_at INTEGER NOT NULL DEFAULT 0;
ALTER TABLE group_v2 ADD blocked_at INTEGER NOT NULL DEFAULT 0;
""");
}
}
}
private static void createUuidMappingTable(

View File

@ -116,7 +116,7 @@ public class SignalAccount implements Closeable {
private static final Logger logger = LoggerFactory.getLogger(SignalAccount.class);
private static final int MINIMUM_STORAGE_VERSION = 1;
private static final int CURRENT_STORAGE_VERSION = 10;
private static final int CURRENT_STORAGE_VERSION = 11;
private final Object LOCK = new Object();
@ -141,6 +141,7 @@ public class SignalAccount implements Closeable {
private MasterKey pinMasterKey;
private StorageKey storageKey;
private AccountEntropyPool accountEntropyPool;
private byte[] authCredentialSalt;
private MediaRootBackupKey mediaRootBackupKey;
private ProfileKey profileKey;
@ -301,6 +302,7 @@ public class SignalAccount implements Closeable {
final IdentityKeyPair pniIdentity,
final ProfileKey profileKey,
final AccountEntropyPool accountEntropyPool,
final byte[] authCredentialSalt,
final MediaRootBackupKey mediaRootBackupKey
) {
this.deviceId = 0;
@ -325,6 +327,7 @@ public class SignalAccount implements Closeable {
this.pinMasterKey = null;
this.accountEntropyPool = null;
}
this.authCredentialSalt = authCredentialSalt;
this.mediaRootBackupKey = mediaRootBackupKey;
getKeyValueStore().storeEntry(storageManifestVersion, -1L);
this.setStorageManifest(null);
@ -361,6 +364,7 @@ public class SignalAccount implements Closeable {
) {
this.pinMasterKey = masterKey;
this.accountEntropyPool = null;
this.authCredentialSalt = null;
getKeyValueStore().storeEntry(storageManifestVersion, -1L);
this.setStorageManifest(null);
this.storageKey = null;
@ -526,6 +530,9 @@ public class SignalAccount implements Closeable {
if (storage.accountEntropyPool != null) {
accountEntropyPool = new AccountEntropyPool(storage.accountEntropyPool);
}
if (storage.authCredentialSalt != null) {
authCredentialSalt = base64.decode(storage.authCredentialSalt);
}
if (storage.mediaRootBackupKey != null) {
mediaRootBackupKey = new MediaRootBackupKey(base64.decode(storage.mediaRootBackupKey));
}
@ -906,6 +913,7 @@ public class SignalAccount implements Closeable {
0,
false,
contact.blocked,
0,
contact.archived,
false,
false,
@ -1014,6 +1022,7 @@ public class SignalAccount implements Closeable {
pinMasterKey == null ? null : base64.encodeToString(pinMasterKey.serialize()),
storageKey == null ? null : base64.encodeToString(storageKey.serialize()),
accountEntropyPool == null ? null : accountEntropyPool.getValue(),
authCredentialSalt == null ? null : base64.encodeToString(authCredentialSalt),
mediaRootBackupKey == null ? null : base64.encodeToString(mediaRootBackupKey.getValue()),
profileKey == null ? null : base64.encodeToString(profileKey.serialize()),
usernameLink == null ? null : base64.encodeToString(usernameLink.getEntropy()),
@ -1228,6 +1237,11 @@ public class SignalAccount implements Closeable {
return pniAccountData.getSignalServiceAccountDataStore();
}
@Override
public SignalServiceAccountDataStore pniOrNull() {
return getPni() != null ? pniAccountData.getSignalServiceAccountDataStore() : null;
}
@Override
public boolean isMultiDevice() {
return SignalAccount.this.isMultiDevice();
@ -1648,6 +1662,10 @@ public class SignalAccount implements Closeable {
save();
}
public byte[] getAuthCredentialSalt() {
return authCredentialSalt;
}
public String getRecoveryPassword() {
final var masterKey = getPinBackedMasterKey();
if (masterKey == null) {
@ -1987,6 +2005,7 @@ public class SignalAccount implements Closeable {
String pinMasterKey,
String storageKey,
String accountEntropyPool,
String authCredentialSalt,
String mediaRootBackupKey,
String profileKey,
String usernameLinkEntropy,

View File

@ -58,6 +58,10 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
public abstract void setBlocked(boolean blocked);
public abstract long getBlockedAt();
public abstract void setBlockedAt(long blockedAt);
public abstract boolean isProfileSharingEnabled();
public abstract void setProfileSharingEnabled(boolean profileSharingEnabled);

View File

@ -24,6 +24,7 @@ public final class GroupInfoV1 extends GroupInfo {
public String color;
public int messageExpirationTime;
public boolean blocked;
private long blockedAt;
public boolean archived;
private byte[] storageRecord;
@ -39,6 +40,7 @@ public final class GroupInfoV1 extends GroupInfo {
final String color,
final int messageExpirationTime,
final boolean blocked,
final long blockedAt,
final boolean archived,
final byte[] storageRecord
) {
@ -49,6 +51,7 @@ public final class GroupInfoV1 extends GroupInfo {
this.color = color;
this.messageExpirationTime = messageExpirationTime;
this.blocked = blocked;
this.blockedAt = blockedAt;
this.archived = archived;
this.storageRecord = storageRecord;
}
@ -91,9 +94,24 @@ public final class GroupInfoV1 extends GroupInfo {
@Override
public void setBlocked(final boolean blocked) {
if (blocked && !this.blocked) {
blockedAt = System.currentTimeMillis();
} else if (!blocked) {
blockedAt = 0;
}
this.blocked = blocked;
}
@Override
public long getBlockedAt() {
return blockedAt;
}
@Override
public void setBlockedAt(final long blockedAt) {
this.blockedAt = blockedAt;
}
@Override
public boolean isProfileSharingEnabled() {
return true;

View File

@ -23,6 +23,7 @@ public final class GroupInfoV2 extends GroupInfo {
private final GroupMasterKey masterKey;
private final DistributionId distributionId;
private boolean blocked;
private long blockedAt;
private boolean profileSharingEnabled;
private DecryptedGroup group;
private byte[] storageRecord;
@ -47,6 +48,7 @@ public final class GroupInfoV2 extends GroupInfo {
final DecryptedGroup group,
final DistributionId distributionId,
final boolean blocked,
final long blockedAt,
final boolean profileSharingEnabled,
final boolean permissionDenied,
final byte[] storageRecord,
@ -57,6 +59,7 @@ public final class GroupInfoV2 extends GroupInfo {
this.group = group;
this.distributionId = distributionId;
this.blocked = blocked;
this.blockedAt = blockedAt;
this.profileSharingEnabled = profileSharingEnabled;
this.permissionDenied = permissionDenied;
this.storageRecord = storageRecord;
@ -186,9 +189,24 @@ public final class GroupInfoV2 extends GroupInfo {
@Override
public void setBlocked(final boolean blocked) {
if (blocked && !this.blocked) {
blockedAt = System.currentTimeMillis();
} else if (!blocked) {
blockedAt = 0;
}
this.blocked = blocked;
}
@Override
public long getBlockedAt() {
return blockedAt;
}
@Override
public void setBlockedAt(final long blockedAt) {
this.blockedAt = blockedAt;
}
@Override
public boolean isProfileSharingEnabled() {
return profileSharingEnabled;

View File

@ -63,6 +63,7 @@ public class GroupStore {
distribution_id BLOB UNIQUE NOT NULL,
endorsement_expiration_time INTEGER NOT NULL DEFAULT 0,
blocked INTEGER NOT NULL DEFAULT FALSE,
blocked_at INTEGER NOT NULL DEFAULT 0,
profile_sharing INTEGER NOT NULL DEFAULT FALSE,
permission_denied INTEGER NOT NULL DEFAULT FALSE
) STRICT;
@ -83,6 +84,7 @@ public class GroupStore {
color TEXT,
expiration_time INTEGER NOT NULL DEFAULT 0,
blocked INTEGER NOT NULL DEFAULT FALSE,
blocked_at INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT FALSE
) STRICT;
CREATE TABLE group_v1_member (
@ -401,6 +403,7 @@ public class GroupStore {
deleteGroup(connection, groupInfoV1.getGroupId());
final var groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey, recipientResolver);
groupInfoV2.setBlocked(groupInfoV1.isBlocked());
groupInfoV2.setBlockedAt(groupInfoV1.getBlockedAt());
updateGroup(connection, groupInfoV2);
logger.debug("Locally migrated group {} to group v2, id: {}",
groupInfoV1.getGroupId().toBase64(),
@ -614,9 +617,9 @@ public class GroupStore {
}
}
final var sql = """
INSERT INTO %s (_id, group_id, group_id_v2, name, color, expiration_time, blocked, archived, storage_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (_id) DO UPDATE SET group_id=excluded.group_id, group_id_v2=excluded.group_id_v2, name=excluded.name, color=excluded.color, expiration_time=excluded.expiration_time, blocked=excluded.blocked, archived=excluded.archived, storage_id=excluded.storage_id
INSERT INTO %s (_id, group_id, group_id_v2, name, color, expiration_time, blocked, blocked_at, archived, storage_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (_id) DO UPDATE SET group_id=excluded.group_id, group_id_v2=excluded.group_id_v2, name=excluded.name, color=excluded.color, expiration_time=excluded.expiration_time, blocked=excluded.blocked, blocked_at=excluded.blocked_at, archived=excluded.archived, storage_id=excluded.storage_id
RETURNING _id
""".formatted(TABLE_GROUP_V1);
try (final var statement = connection.prepareStatement(sql)) {
@ -631,8 +634,9 @@ public class GroupStore {
statement.setString(5, groupV1.color);
statement.setLong(6, groupV1.getMessageExpirationTimer());
statement.setBoolean(7, groupV1.isBlocked());
statement.setBoolean(8, groupV1.archived);
statement.setBytes(9, KeyUtils.createRawStorageId());
statement.setLong(8, groupV1.getBlockedAt());
statement.setBoolean(9, groupV1.archived);
statement.setBytes(10, KeyUtils.createRawStorageId());
final var generatedKey = Utils.executeQueryForOptional(statement, Utils::getIdMapper);
if (internalId == null) {
@ -658,9 +662,9 @@ public class GroupStore {
} else if (group instanceof GroupInfoV2 groupV2) {
final var sql = (
"""
INSERT INTO %s (_id, group_id, master_key, group_data, distribution_id, blocked, permission_denied, storage_id, profile_sharing)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (_id) DO UPDATE SET group_id=excluded.group_id, master_key=excluded.master_key, group_data=excluded.group_data, distribution_id=excluded.distribution_id, blocked=excluded.blocked, permission_denied=excluded.permission_denied, storage_id=excluded.storage_id, profile_sharing=excluded.profile_sharing
INSERT INTO %s (_id, group_id, master_key, group_data, distribution_id, blocked, blocked_at, permission_denied, storage_id, profile_sharing)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (_id) DO UPDATE SET group_id=excluded.group_id, master_key=excluded.master_key, group_data=excluded.group_data, distribution_id=excluded.distribution_id, blocked=excluded.blocked, blocked_at=excluded.blocked_at, permission_denied=excluded.permission_denied, storage_id=excluded.storage_id, profile_sharing=excluded.profile_sharing
"""
).formatted(TABLE_GROUP_V2);
try (final var statement = connection.prepareStatement(sql)) {
@ -678,9 +682,10 @@ public class GroupStore {
}
statement.setBytes(5, UuidUtil.toByteArray(groupV2.getDistributionId().asUuid()));
statement.setBoolean(6, groupV2.isBlocked());
statement.setBoolean(7, groupV2.isPermissionDenied());
statement.setBytes(8, KeyUtils.createRawStorageId());
statement.setBoolean(9, groupV2.isProfileSharingEnabled());
statement.setLong(7, groupV2.getBlockedAt());
statement.setBoolean(8, groupV2.isPermissionDenied());
statement.setBytes(9, KeyUtils.createRawStorageId());
statement.setBoolean(10, groupV2.isProfileSharingEnabled());
statement.executeUpdate();
}
} else {
@ -691,7 +696,7 @@ public class GroupStore {
private List<GroupInfoV2> getGroupsV2() {
final var sql = (
"""
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.profile_sharing, g.permission_denied, g.storage_record
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.blocked_at, g.profile_sharing, g.permission_denied, g.storage_record
FROM %s g
"""
).formatted(TABLE_GROUP_V2);
@ -709,7 +714,7 @@ public class GroupStore {
public GroupInfoV2 getGroup(Connection connection, GroupIdV2 groupIdV2) throws SQLException {
final var sql = (
"""
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.profile_sharing, g.permission_denied, g.storage_record
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.blocked_at, g.profile_sharing, g.permission_denied, g.storage_record
FROM %s g
WHERE g.group_id = ?
"""
@ -743,7 +748,7 @@ public class GroupStore {
public GroupInfoV2 getGroupV2(Connection connection, StorageId storageId) throws SQLException {
final var sql = (
"""
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.profile_sharing, g.permission_denied, g.storage_record
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.blocked_at, g.profile_sharing, g.permission_denied, g.storage_record
FROM %s g
WHERE g.storage_id = ?
"""
@ -766,6 +771,7 @@ public class GroupStore {
final var groupData = resultSet.getBytes("group_data");
final var distributionId = resultSet.getBytes("distribution_id");
final var blocked = resultSet.getBoolean("blocked");
final var blockedAt = resultSet.getLong("blocked_at");
final var profileSharingEnabled = resultSet.getBoolean("profile_sharing");
final var permissionDenied = resultSet.getBoolean("permission_denied");
final var storageRecord = resultSet.getBytes("storage_record");
@ -774,6 +780,7 @@ public class GroupStore {
groupData == null ? null : DecryptedGroup.ADAPTER.decode(groupData),
DistributionId.from(UuidUtil.parseOrThrow(distributionId)),
blocked,
blockedAt,
profileSharingEnabled,
permissionDenied,
storageRecord,
@ -800,7 +807,7 @@ public class GroupStore {
private List<GroupInfoV1> getGroupsV1() {
final var sql = (
"""
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived, g.storage_record
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.blocked_at, g.archived, g.storage_record
FROM %s g
"""
).formatted(TABLE_GROUP_V1_MEMBER, TABLE_GROUP_V1);
@ -818,7 +825,7 @@ public class GroupStore {
public GroupInfoV1 getGroup(Connection connection, GroupIdV1 groupIdV1) throws SQLException {
final var sql = (
"""
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived, g.storage_record
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.blocked_at, g.archived, g.storage_record
FROM %s g
WHERE g.group_id = ?
"""
@ -852,7 +859,7 @@ public class GroupStore {
public GroupInfoV1 getGroupV1(Connection connection, StorageId storageId) throws SQLException {
final var sql = (
"""
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived, g.storage_record
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.blocked_at, g.archived, g.storage_record
FROM %s g
WHERE g.storage_id = ?
"""
@ -882,6 +889,7 @@ public class GroupStore {
.collect(Collectors.toSet());
final var expirationTime = resultSet.getInt("expiration_time");
final var blocked = resultSet.getBoolean("blocked");
final var blockedAt = resultSet.getLong("blocked_at");
final var archived = resultSet.getBoolean("archived");
final var storageRecord = resultSet.getBytes("storage_record");
return new GroupInfoV1(GroupId.v1(groupId),
@ -891,6 +899,7 @@ public class GroupStore {
color,
expirationTime,
blocked,
blockedAt,
archived,
storageRecord);
}
@ -902,7 +911,7 @@ public class GroupStore {
private GroupInfoV1 getGroupV1ByV2Id(Connection connection, GroupIdV2 groupIdV2) throws SQLException {
final var sql = (
"""
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived, g.storage_record
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.blocked_at, g.archived, g.storage_record
FROM %s g
WHERE g.group_id_v2 = ?
"""

View File

@ -59,6 +59,7 @@ public class LegacyGroupStore {
g1.color,
g1.messageExpirationTime,
g1.blocked,
0,
g1.archived,
null);
}
@ -77,6 +78,7 @@ public class LegacyGroupStore {
loadDecryptedGroupLocked(groupId, groupCachePath),
g2.distributionId == null ? DistributionId.create() : DistributionId.from(g2.distributionId),
g2.blocked,
0,
true,
g2.permissionDenied,
null,

View File

@ -50,6 +50,7 @@ public class LegacyRecipientStore2 {
0,
false,
r.contact.blocked,
0,
r.contact.archived,
r.contact.profileSharingEnabled,
false,

View File

@ -96,6 +96,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
expiration_time_version INTEGER DEFAULT 1 NOT NULL,
mute_until INTEGER NOT NULL DEFAULT 0,
blocked INTEGER NOT NULL DEFAULT FALSE,
blocked_at INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT FALSE,
profile_sharing INTEGER NOT NULL DEFAULT FALSE,
hide_story INTEGER NOT NULL DEFAULT FALSE,
@ -351,7 +352,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
public List<Pair<RecipientId, Contact>> getContacts() {
final var sql = (
"""
SELECT r._id, r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp
SELECT r._id, r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp
FROM %s r
WHERE (r.number IS NOT NULL OR r.pni IS NOT NULL OR r.aci IS NOT NULL) AND %s AND r.hidden = FALSE
"""
@ -376,7 +377,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
r.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential,
r.pni_signature_verified,
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp,
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp,
r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities, r.profile_phone_number_sharing,
r.discoverable,
r.storage_record
@ -397,7 +398,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
r.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential,
r.pni_signature_verified,
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp,
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp,
r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities, r.profile_phone_number_sharing,
r.discoverable,
r.storage_record
@ -447,7 +448,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
r.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential,
r.pni_signature_verified,
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp,
r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp,
r.profile_last_update_timestamp, r.profile_given_name, r.profile_family_name, r.profile_about, r.profile_about_emoji, r.profile_avatar_url_path, r.profile_mobile_coin_address, r.profile_unidentified_access_mode, r.profile_capabilities, r.profile_phone_number_sharing,
r.discoverable,
r.storage_record
@ -892,7 +893,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = (
"""
UPDATE %s
SET given_name = ?, family_name = ?, nick_name = ?, expiration_time = ?, expiration_time_version = ?, mute_until = ?, hide_story = ?, profile_sharing = ?, color = ?, blocked = ?, archived = ?, unregistered_timestamp = ?, nick_name_given_name = ?, nick_name_family_name = ?, note = ?, hidden = ?
SET given_name = ?, family_name = ?, nick_name = ?, expiration_time = ?, expiration_time_version = ?, mute_until = ?, hide_story = ?, profile_sharing = ?, color = ?, blocked = ?, blocked_at = ?, archived = ?, unregistered_timestamp = ?, nick_name_given_name = ?, nick_name_family_name = ?, note = ?, hidden = ?
WHERE _id = ?
"""
).formatted(TABLE_RECIPIENT);
@ -907,25 +908,49 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
statement.setBoolean(8, contact != null && contact.isProfileSharingEnabled());
statement.setString(9, contact == null ? null : contact.color());
statement.setBoolean(10, contact != null && contact.isBlocked());
statement.setBoolean(11, contact != null && contact.isArchived());
statement.setLong(11, contact == null ? 0 : contact.blockedAt());
statement.setBoolean(12, contact != null && contact.isArchived());
if (contact == null || contact.unregisteredTimestamp() == null) {
statement.setNull(12, Types.INTEGER);
statement.setNull(13, Types.INTEGER);
} else {
statement.setLong(12, contact.unregisteredTimestamp());
statement.setLong(13, contact.unregisteredTimestamp());
}
statement.setString(13, contact == null ? null : contact.nickNameGivenName());
statement.setString(14, contact == null ? null : contact.nickNameFamilyName());
statement.setString(15, contact == null ? null : contact.note());
statement.setBoolean(16, contact != null && contact.isHidden());
statement.setLong(17, recipientId.id());
statement.setString(14, contact == null ? null : contact.nickNameGivenName());
statement.setString(15, contact == null ? null : contact.nickNameFamilyName());
statement.setString(16, contact == null ? null : contact.note());
statement.setBoolean(17, contact != null && contact.isHidden());
statement.setLong(18, recipientId.id());
statement.executeUpdate();
}
if (contact != null && contact.unregisteredTimestamp() != null) {
markUnregisteredAndSplitIfNecessary(connection, recipientId);
markUnregisteredAndSplitIfNecessary(connection, recipientId, contact.unregisteredTimestamp());
}
rotateStorageId(connection, recipientId);
}
public void splitForStorageSyncIfNecessary(final Connection connection, final ACI aci) throws SQLException {
final var recipient = findByServiceId(connection, aci);
if (recipient.isEmpty()) {
return;
}
final var recipientId = recipient.get().id();
final var address = recipient.get().address();
if (address.pni().isEmpty() && address.number().isEmpty()) {
return;
}
logger.debug("Splitting {} for storage sync", recipientId);
final var splitAddress = new RecipientAddress(Optional.empty(),
address.pni(),
address.number(),
Optional.empty());
updateRecipientAddress(connection,
recipientId,
new RecipientAddress(address.aci(), Optional.empty(), Optional.empty(), address.username()));
resolveRecipientTrusted(connection, splitAddress);
}
public int removeStorageIdsFromLocalOnlyUnregisteredRecipients(
final Connection connection,
final Collection<StorageId> storageIds
@ -1061,7 +1086,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
if (recipientAddress.get().address().aci().isEmpty() || (
contact != null && contact.unregisteredTimestamp() != null
)) {
markUnregisteredAndSplitIfNecessary(connection, recipientId);
markUnregisteredAndSplitIfNecessary(connection, recipientId, System.currentTimeMillis());
}
}
}
@ -1095,7 +1120,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
if (registered) {
markRegistered(connection, recipientId);
} else {
markUnregisteredAndSplitIfNecessary(connection, recipientId);
markUnregisteredAndSplitIfNecessary(connection, recipientId, System.currentTimeMillis());
}
connection.commit();
} catch (SQLException e) {
@ -1105,9 +1130,10 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
private void markUnregisteredAndSplitIfNecessary(
final Connection connection,
final RecipientId recipientId
final RecipientId recipientId,
final long unregisteredTimestamp
) throws SQLException {
markUnregistered(connection, recipientId);
markUnregistered(connection, recipientId, unregisteredTimestamp);
final var address = resolveRecipientAddress(connection, recipientId);
final var needSplit = address.aci().isPresent() && address.pni().isPresent();
logger.trace("Marking unregistered recipient {} as unregistered (and split={}): {}",
@ -1154,7 +1180,11 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
}
}
private void markUnregistered(final Connection connection, final RecipientId recipientId) throws SQLException {
private void markUnregistered(
final Connection connection,
final RecipientId recipientId,
final long unregisteredTimestamp
) throws SQLException {
final var sql = (
"""
UPDATE %s
@ -1163,7 +1193,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
"""
).formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) {
statement.setLong(1, System.currentTimeMillis());
statement.setLong(1, unregisteredTimestamp);
statement.setLong(2, recipientId.id());
statement.executeUpdate();
}
@ -1488,9 +1518,14 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
RecipientId toBeMergedRecipientId
) throws SQLException {
final var contact = getContact(connection, recipientId);
final var toBeMergedContact = getContact(connection, toBeMergedRecipientId);
if (contact == null) {
final var toBeMergedContact = getContact(connection, toBeMergedRecipientId);
storeContact(connection, recipientId, toBeMergedContact);
} else if (toBeMergedContact != null) {
final var mergedContact = mergeContacts(contact, toBeMergedContact);
if (!contact.equals(mergedContact)) {
storeContact(connection, recipientId, mergedContact);
}
}
final var profileKey = getProfileKey(connection, recipientId);
@ -1515,6 +1550,24 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
recipientsMerged.put(toBeMergedRecipientId.id(), recipientId.id());
}
static Contact mergeContacts(final Contact primary, final Contact secondary) {
final var profileSharingEnabled = primary.isProfileSharingEnabled() || secondary.isProfileSharingEnabled();
return Contact.newBuilder(primary)
.withGivenName(secondary.givenName())
.withFamilyName(secondary.familyName())
.withMessageExpirationTime(primary.messageExpirationTime() > 0
? primary.messageExpirationTime()
: secondary.messageExpirationTime())
.withMessageExpirationTimeVersion(Math.max(primary.messageExpirationTimeVersion(),
secondary.messageExpirationTimeVersion()))
.withMuteUntil(primary.muteUntil() > 0 ? primary.muteUntil() : secondary.muteUntil())
.withIsBlocked(primary.isBlocked() || secondary.isBlocked())
.withBlockedAt(Math.max(primary.blockedAt(), secondary.blockedAt()))
.withIsProfileSharingEnabled(profileSharingEnabled)
.withIsHidden(profileSharingEnabled ? false : primary.isHidden())
.build();
}
private Optional<RecipientWithAddress> findByNumber(
final Connection connection,
final String number
@ -1594,7 +1647,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
private Contact getContact(final Connection connection, final RecipientId recipientId) throws SQLException {
final var sql = (
"""
SELECT r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.archived, r.hidden, r.unregistered_timestamp
SELECT r.given_name, r.family_name, r.nick_name, r.nick_name_given_name, r.nick_name_family_name, r.note, r.expiration_time, r.expiration_time_version, r.mute_until, r.hide_story, r.profile_sharing, r.color, r.blocked, r.blocked_at, r.archived, r.hidden, r.unregistered_timestamp
FROM %s r
WHERE r._id = ? AND (%s)
"""
@ -1699,6 +1752,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
resultSet.getLong("mute_until"),
resultSet.getBoolean("hide_story"),
resultSet.getBoolean("blocked"),
resultSet.getLong("blocked_at"),
resultSet.getBoolean("archived"),
resultSet.getBoolean("profile_sharing"),
resultSet.getBoolean("hidden"),

View File

@ -298,7 +298,7 @@ public class StickerStore {
"""
SELECT s.pack_id
FROM %s s
WHERE s.storage_id IS NULL AND (s.installed = TRUE OR s.deleted_timestamp > 0)
WHERE s.storage_id IS NULL AND s.installed = TRUE
"""
).formatted(TABLE_STICKER);
final var updateSql = (

View File

@ -24,6 +24,7 @@ import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.Id
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Arrays;
import java.util.Collection;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Pattern;
@ -56,6 +57,29 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
this.selfNumber = account.getNumber();
}
public void prepare(final Collection<SignalContactRecord> remoteRecords) throws SQLException {
for (final var remoteRecord : remoteRecords) {
if (isInvalid(remoteRecord)) {
continue;
}
final var remote = remoteRecord.getProto();
final var aci = ACI.parseOrNull(remote.aci, remote.aciBinary);
final var pni = PNI.parseOrNull(remote.pni, remote.pniBinary);
if (shouldSplitForStorageSync(remote.unregisteredAtTimestamp, aci, pni, remote.e164)) {
account.getRecipientStore().splitForStorageSyncIfNecessary(connection, aci);
}
}
}
static boolean shouldSplitForStorageSync(
final long unregisteredAtTimestamp,
final ACI aci,
final PNI pni,
final String e164
) {
return unregisteredAtTimestamp > 0 && aci != null && pni == null && e164.isEmpty();
}
/**
* Error cases:
* - You can't have a contact record without an ACI or PNI.
@ -205,6 +229,7 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
.identityState(identityState)
.identityKey(identityKey)
.blocked(remote.blocked)
.blockedAtTimestamp(remote.blockedAtTimestamp)
.whitelisted(remote.whitelisted)
.archived(remote.archived)
.markedUnread(remote.markedUnread)
@ -283,7 +308,9 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
final var contactNickGivenName = contact == null ? null : contact.nickNameGivenName();
final var contactNickFamilyName = contact == null ? null : contact.nickNameFamilyName();
final var contactNote = contact == null ? null : contact.note();
final var blockedAt = contact == null ? 0 : contact.blockedAt();
if (blocked != contactProto.blocked
|| blockedAt != contactProto.blockedAtTimestamp
|| profileShared != contactProto.whitelisted
|| archived != contactProto.archived
|| hidden != contactProto.hidden
@ -301,6 +328,7 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
logger.debug("Storing new or updated contact {}", recipientId);
final var contactBuilder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact);
final var newContact = contactBuilder.withIsBlocked(contactProto.blocked)
.withBlockedAt(contactProto.blocked ? contactProto.blockedAtTimestamp : 0)
.withIsProfileSharingEnabled(contactProto.whitelisted)
.withIsArchived(contactProto.archived)
.withIsHidden(contactProto.hidden)

View File

@ -114,6 +114,7 @@ public final class GroupV1RecordProcessor extends DefaultStorageRecordProcessor<
final var group = account.getGroupStore().getOrCreateGroupV1(connection, groupIdV1);
if (group != null) {
group.setBlocked(groupV1Proto.blocked);
group.setBlockedAt(0);
account.getGroupStore().updateGroup(connection, group);
account.getGroupStore()
.storeStorageRecord(connection, group.getGroupId(), groupV1Record.getId(), groupV1Proto.encode());

View File

@ -56,6 +56,7 @@ public final class GroupV2RecordProcessor extends DefaultStorageRecordProcessor<
final var mergedBuilder = remote.newBuilder()
.masterKey(remote.masterKey)
.blocked(remote.blocked)
.blockedAtTimestamp(remote.blockedAtTimestamp)
.whitelisted(remote.whitelisted)
.archived(remote.archived)
.markedUnread(remote.markedUnread)
@ -93,6 +94,7 @@ public final class GroupV2RecordProcessor extends DefaultStorageRecordProcessor<
final var group = account.getGroupStore().getGroupOrPartialMigrate(connection, groupMasterKey);
group.setBlocked(groupV2Proto.blocked);
group.setBlockedAt(groupV2Proto.blocked ? groupV2Proto.blockedAtTimestamp : 0);
group.setProfileSharingEnabled(groupV2Proto.whitelisted);
account.getGroupStore().updateGroup(connection, group);
account.getGroupStore()

View File

@ -63,16 +63,17 @@ public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor<Si
final var remote = remoteRecord.getProto();
final var local = localRecord.getProto();
final var isRemoteDeleted = remote.deletedAtTimestamp > 0;
final var isLocalDeleted = local.deletedAtTimestamp > 0;
if (isRemoteDeleted && isLocalDeleted && local.deletedAtTimestamp > remote.deletedAtTimestamp) {
if (shouldKeepLocalDeletion(remote.deletedAtTimestamp, local.deletedAtTimestamp)) {
return localRecord;
}
return remoteRecord;
}
static boolean shouldKeepLocalDeletion(final long remoteDeletedAt, final long localDeletedAt) {
return remoteDeletedAt > 0 && localDeletedAt > 0 && localDeletedAt < remoteDeletedAt;
}
@Override
protected void insertLocal(final SignalStickerPackRecord record) throws SQLException {
account.getStickerStore().upsertFromStorageSync(connection, record);

View File

@ -0,0 +1,139 @@
package org.asamk.signal.manager.syncStorage;
import org.asamk.signal.manager.util.LeakyBucket;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.BooleanSupplier;
public final class StorageSyncLoopDetector {
private static final int FINGERPRINT_HISTORY = 3;
private final BooleanSupplier isMultiDevice;
private final List<Integer> recentFingerprints = new ArrayList<>();
private final LeakyBucket contentBucket = new LeakyBucket(3,
Duration.ofHours(1).toMillis(),
new InMemoryBucketState());
private final LeakyBucket rateBucket = new LeakyBucket(100,
Duration.ofMinutes(10).toMillis(),
new InMemoryBucketState());
public StorageSyncLoopDetector(final BooleanSupplier isMultiDevice) {
this.isMultiDevice = isMultiDevice;
}
public synchronized Decision onWriteAttempt(
final WriteOperationResult write,
final boolean fetchedRemoteManifest,
final boolean isRetry
) {
return onWriteAttempt(write, fetchedRemoteManifest, isRetry, System.currentTimeMillis());
}
synchronized Decision onWriteAttempt(
final WriteOperationResult write,
final boolean fetchedRemoteManifest,
final boolean isRetry,
final long now
) {
if (!isMultiDevice.getAsBoolean() || isRetry) {
return Decision.Allowed.INSTANCE;
}
final var fingerprint = fingerprint(write);
final var chargeContent = fetchedRemoteManifest && fingerprint != null && recentFingerprints.contains(
fingerprint);
if (chargeContent && !contentBucket.hasRoom(now)) {
return new Decision.Denied(Cause.REPEATED_PAYLOAD, contentBucket.level(now));
}
if (fetchedRemoteManifest && !rateBucket.hasRoom(now)) {
return new Decision.Denied(Cause.WRITE_RATE, rateBucket.level(now));
}
if (chargeContent) {
contentBucket.use(now);
}
if (fetchedRemoteManifest) {
rateBucket.use(now);
}
if (fingerprint != null) {
remember(fingerprint);
}
return Decision.Allowed.INSTANCE;
}
public synchronized void onWriteFailed() {
onWriteFailed(System.currentTimeMillis());
}
synchronized void onWriteFailed(final long now) {
contentBucket.refund(now);
rateBucket.refund(now);
}
public synchronized void onConverged() {
contentBucket.clear();
}
private void remember(final int fingerprint) {
recentFingerprints.remove(Integer.valueOf(fingerprint));
recentFingerprints.addFirst(fingerprint);
if (recentFingerprints.size() > FINGERPRINT_HISTORY) {
recentFingerprints.removeLast();
}
}
private static Integer fingerprint(final WriteOperationResult write) {
if (write.inserts().isEmpty()) {
return null;
}
return write.inserts()
.stream()
.map(record -> Arrays.hashCode(record.getProto().encode()))
.sorted()
.toList()
.hashCode();
}
private static final class InMemoryBucketState implements LeakyBucket.State {
private int level;
private long levelUpdatedAt;
@Override
public int level() {
return level;
}
@Override
public long levelUpdatedAt() {
return levelUpdatedAt;
}
@Override
public void update(final int level, final long levelUpdatedAt) {
this.level = level;
this.levelUpdatedAt = levelUpdatedAt;
}
}
public enum Cause {
REPEATED_PAYLOAD,
WRITE_RATE
}
public sealed interface Decision {
enum Allowed implements Decision {
INSTANCE
}
record Denied(Cause cause, int level) implements Decision {}
}
}

View File

@ -123,6 +123,7 @@ public final class StorageSyncModels {
.nickname(getNicknameRemoteRecord(recipient.getContact()))
.note(emptyIfNull(recipient.getContact().note()))
.blocked(recipient.getContact().isBlocked())
.blockedAtTimestamp(recipient.getContact().blockedAt())
.whitelisted(recipient.getContact().isProfileSharingEnabled())
.mutedUntilTimestamp(recipient.getContact().muteUntil())
.hideStory(recipient.getContact().hideStory())
@ -161,6 +162,7 @@ public final class StorageSyncModels {
final var builder = SignalGroupV2Record.Companion.newBuilder(group.getStorageRecord());
builder.masterKey(ByteString.of(group.getMasterKey().serialize()));
builder.blocked(group.isBlocked());
builder.blockedAtTimestamp(group.getBlockedAt());
builder.whitelisted(group.isProfileSharingEnabled());
return builder.build();
}

View File

@ -0,0 +1,60 @@
package org.asamk.signal.manager.util;
public final class LeakyBucket {
private final int capacity;
private final long dripIntervalMillis;
private final State state;
public LeakyBucket(final int capacity, final long dripIntervalMillis, final State state) {
this.capacity = capacity;
this.dripIntervalMillis = dripIntervalMillis;
this.state = state;
}
public int level(final long now) {
return calculateStateForCurrentTime(now).level();
}
public boolean hasRoom(final long now) {
return level(now) < capacity;
}
public void use(final long now) {
final var currentState = calculateStateForCurrentTime(now);
state.update(currentState.level() + 1, currentState.levelUpdatedAt());
}
public void refund(final long now) {
final var currentState = calculateStateForCurrentTime(now);
state.update(Math.max(currentState.level() - 1, 0), currentState.levelUpdatedAt());
}
public void clear() {
state.update(0, 0);
}
private Snapshot calculateStateForCurrentTime(final long now) {
final var level = state.level();
final var levelUpdatedAt = state.levelUpdatedAt();
final var elapsed = now - levelUpdatedAt;
if (level <= 0 || elapsed < 0) {
return new Snapshot(0, now);
}
final var drips = elapsed / dripIntervalMillis;
return new Snapshot((int) Math.max(level - drips, 0), levelUpdatedAt + dripIntervalMillis * drips);
}
private record Snapshot(int level, long levelUpdatedAt) {}
public interface State {
int level();
long levelUpdatedAt();
void update(int level, long levelUpdatedAt);
}
}

View File

@ -2,7 +2,6 @@ package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupIdV2;
import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId;
@ -61,6 +60,7 @@ class GroupInfoTerminatedTest {
group,
DistributionId.create(),
false,
0,
false,
false,
null,

View File

@ -0,0 +1,93 @@
package org.asamk.signal.manager.storage.recipients;
import org.asamk.signal.manager.api.Contact;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RecipientStoreTest {
@Test
void mergeContactsUsesAndroidMergePolicy() {
final var primary = Contact.newBuilder()
.withGivenName("Primary given")
.withFamilyName("Primary family")
.withNickName("Primary system nickname")
.withNickNameGivenName("Primary nickname given")
.withNickNameFamilyName("Primary nickname family")
.withNote("Primary note")
.withColor("Primary color")
.withMessageExpirationTimeVersion(2)
.withHideStory(true)
.withIsBlocked(false)
.withBlockedAt(100)
.withIsArchived(true)
.withIsHidden(true)
.withUnregisteredTimestamp(300L)
.build();
final var secondary = Contact.newBuilder()
.withGivenName("Secondary given")
.withFamilyName("Secondary family")
.withNickName("Secondary system nickname")
.withNickNameGivenName("Secondary nickname given")
.withNickNameFamilyName("Secondary nickname family")
.withNote("Secondary note")
.withColor("Secondary color")
.withMessageExpirationTime(60)
.withMessageExpirationTimeVersion(3)
.withMuteUntil(400)
.withIsBlocked(true)
.withBlockedAt(200)
.withIsProfileSharingEnabled(true)
.withIsHidden(true)
.withUnregisteredTimestamp(500L)
.build();
final var merged = RecipientStore.mergeContacts(primary, secondary);
assertEquals("Secondary given", merged.givenName());
assertEquals("Secondary family", merged.familyName());
assertEquals("Primary system nickname", merged.nickName());
assertEquals("Primary nickname given", merged.nickNameGivenName());
assertEquals("Primary nickname family", merged.nickNameFamilyName());
assertEquals("Primary note", merged.note());
assertEquals("Primary color", merged.color());
assertEquals(60, merged.messageExpirationTime());
assertEquals(3, merged.messageExpirationTimeVersion());
assertEquals(400, merged.muteUntil());
assertTrue(merged.hideStory());
assertTrue(merged.isBlocked());
assertEquals(200, merged.blockedAt());
assertTrue(merged.isArchived());
assertTrue(merged.isProfileSharingEnabled());
assertFalse(merged.isHidden());
assertEquals(300L, merged.unregisteredTimestamp());
}
@Test
void mergeContactsPrefersConfiguredPrimaryValues() {
final var primary = Contact.newBuilder()
.withColor("Primary color")
.withMessageExpirationTime(30)
.withMessageExpirationTimeVersion(4)
.withMuteUntil(100)
.withIsHidden(true)
.build();
final var secondary = Contact.newBuilder()
.withColor("Secondary color")
.withMessageExpirationTime(60)
.withMessageExpirationTimeVersion(3)
.withMuteUntil(200)
.build();
final var merged = RecipientStore.mergeContacts(primary, secondary);
assertEquals("Primary color", merged.color());
assertEquals(30, merged.messageExpirationTime());
assertEquals(4, merged.messageExpirationTimeVersion());
assertEquals(100, merged.muteUntil());
assertTrue(merged.isHidden());
}
}

View File

@ -0,0 +1,33 @@
package org.asamk.signal.manager.syncStorage;
import org.junit.jupiter.api.Test;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class StorageRecordProcessorTest {
@Test
void splitsOnlyUnregisteredAciOnlyRecords() {
final var aci = ACI.from(UUID.randomUUID());
final var pni = PNI.from(UUID.randomUUID());
assertTrue(ContactRecordProcessor.shouldSplitForStorageSync(1, aci, null, ""));
assertFalse(ContactRecordProcessor.shouldSplitForStorageSync(0, aci, null, ""));
assertFalse(ContactRecordProcessor.shouldSplitForStorageSync(1, null, null, ""));
assertFalse(ContactRecordProcessor.shouldSplitForStorageSync(1, aci, pni, ""));
assertFalse(ContactRecordProcessor.shouldSplitForStorageSync(1, aci, null, "+12025550123"));
}
@Test
void keepsOlderLocalStickerDeletion() {
assertTrue(StickerPackRecordProcessor.shouldKeepLocalDeletion(200, 100));
assertFalse(StickerPackRecordProcessor.shouldKeepLocalDeletion(100, 200));
assertFalse(StickerPackRecordProcessor.shouldKeepLocalDeletion(200, 0));
assertFalse(StickerPackRecordProcessor.shouldKeepLocalDeletion(0, 100));
}
}

View File

@ -0,0 +1,102 @@
package org.asamk.signal.manager.syncStorage;
import org.junit.jupiter.api.Test;
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
import org.whispersystems.signalservice.api.storage.StorageId;
import org.whispersystems.signalservice.internal.storage.protos.StorageRecord;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
class StorageSyncLoopDetectorTest {
private static final long NOW = 1_700_000_000_000L;
@Test
void repeatedPayloadIsDeniedAfterThreeCharges() {
final var detector = new StorageSyncLoopDetector(() -> true);
final var write = writeWithInsert(1);
assertAllowed(detector.onWriteAttempt(write, true, false, NOW));
for (var index = 0; index < 3; index++) {
assertAllowed(detector.onWriteAttempt(write, true, false, NOW));
}
assertEquals(new StorageSyncLoopDetector.Decision.Denied(StorageSyncLoopDetector.Cause.REPEATED_PAYLOAD, 3),
detector.onWriteAttempt(write, true, false, NOW));
}
@Test
void storageIdsAreExcludedFromPayloadFingerprint() {
final var detector = new StorageSyncLoopDetector(() -> true);
assertAllowed(detector.onWriteAttempt(writeWithInsert(1), true, false, NOW));
assertAllowed(detector.onWriteAttempt(writeWithInsert(2), true, false, NOW));
assertAllowed(detector.onWriteAttempt(writeWithInsert(3), true, false, NOW));
assertAllowed(detector.onWriteAttempt(writeWithInsert(4), true, false, NOW));
assertInstanceOf(StorageSyncLoopDetector.Decision.Denied.class,
detector.onWriteAttempt(writeWithInsert(5), true, false, NOW));
}
@Test
void convergenceClearsTheContentBucket() {
final var detector = new StorageSyncLoopDetector(() -> true);
final var write = writeWithInsert(1);
for (var index = 0; index < 4; index++) {
assertAllowed(detector.onWriteAttempt(write, true, false, NOW));
}
detector.onConverged();
assertAllowed(detector.onWriteAttempt(write, true, false, NOW));
}
@Test
void failedWritesAreRefunded() {
final var detector = new StorageSyncLoopDetector(() -> true);
final var write = writeWithInsert(1);
for (var index = 0; index < 20; index++) {
assertAllowed(detector.onWriteAttempt(write, true, false, NOW));
detector.onWriteFailed(NOW);
}
}
@Test
void rateBucketLimitsDeleteOnlyWrites() {
final var detector = new StorageSyncLoopDetector(() -> true);
final var write = new WriteOperationResult(null, List.of(), List.of(new byte[]{1}));
for (var index = 0; index < 100; index++) {
assertAllowed(detector.onWriteAttempt(write, true, false, NOW));
}
assertEquals(new StorageSyncLoopDetector.Decision.Denied(StorageSyncLoopDetector.Cause.WRITE_RATE, 100),
detector.onWriteAttempt(write, true, false, NOW));
}
@Test
void retriesAndSingleDeviceWritesAreExempt() {
final var retryDetector = new StorageSyncLoopDetector(() -> true);
final var singleDeviceDetector = new StorageSyncLoopDetector(() -> false);
final var write = writeWithInsert(1);
for (var index = 0; index < 20; index++) {
assertAllowed(retryDetector.onWriteAttempt(write, true, true, NOW));
assertAllowed(singleDeviceDetector.onWriteAttempt(write, true, false, NOW));
}
}
private static WriteOperationResult writeWithInsert(final int storageIdByte) {
final var storageId = StorageId.forType(new byte[]{(byte) storageIdByte}, 99);
final var record = new SignalStorageRecord(storageId, new StorageRecord.Builder().build());
return new WriteOperationResult(null, List.of(record), List.of());
}
private static void assertAllowed(final StorageSyncLoopDetector.Decision decision) {
assertEquals(StorageSyncLoopDetector.Decision.Allowed.INSTANCE, decision);
}
}

View File

@ -0,0 +1,66 @@
package org.asamk.signal.manager.util;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LeakyBucketTest {
private static final long NOW = 1_700_000_000_000L;
@Test
void dripsLevelsAndCarriesPartialIntervals() {
final var state = new TestState();
final var bucket = new LeakyBucket(3, Duration.ofHours(1).toMillis(), state);
bucket.use(NOW);
bucket.use(NOW);
bucket.use(NOW);
assertEquals(2, bucket.level(NOW + Duration.ofMinutes(90).toMillis()));
assertEquals(1, bucket.level(NOW + Duration.ofMinutes(121).toMillis()));
}
@Test
void refundNeverDropsBelowZero() {
final var bucket = new LeakyBucket(1, 1_000, new TestState());
bucket.refund(NOW);
assertEquals(0, bucket.level(NOW));
assertTrue(bucket.hasRoom(NOW));
}
@Test
void clockMovingBackwardsRefillsTheBucket() {
final var bucket = new LeakyBucket(1, 1_000, new TestState());
bucket.use(NOW);
assertTrue(bucket.hasRoom(NOW - 1));
}
private static final class TestState implements LeakyBucket.State {
private int level;
private long levelUpdatedAt;
@Override
public int level() {
return level;
}
@Override
public long levelUpdatedAt() {
return levelUpdatedAt;
}
@Override
public void update(final int level, final long levelUpdatedAt) {
this.level = level;
this.levelUpdatedAt = levelUpdatedAt;
}
}
}

View File

@ -839,6 +839,7 @@ public class DbusManagerImpl implements Manager {
0,
false,
contactBlocked,
0,
false,
false,
false,

View File

@ -3768,6 +3768,32 @@
"java.lang.String"
]
},
{
"name": "<init>",
"parameterTypes": [
"int",
"long",
"java.lang.String",
"boolean",
"java.lang.String",
"java.lang.String",
"java.lang.String",
"int",
"boolean",
"java.lang.String",
"org.asamk.signal.manager.storage.SignalAccount$Storage$AccountData",
"org.asamk.signal.manager.storage.SignalAccount$Storage$AccountData",
"java.lang.String",
"java.lang.String",
"java.lang.String",
"java.lang.String",
"java.lang.String",
"java.lang.String",
"java.lang.String",
"java.lang.String",
"java.lang.String"
]
},
{
"name": "<init>",
"parameterTypes": [
@ -3818,6 +3844,10 @@
"name": "aciAccountData",
"parameterTypes": []
},
{
"name": "authCredentialSalt",
"parameterTypes": []
},
{
"name": "deviceId",
"parameterTypes": []