Compare commits

...

14 Commits

Author SHA1 Message Date
AsamK
3c17e01c37 Fix storage sync issues 2026-08-21 21:39:51 +02:00
AsamK
9605c94f09 Improve contact merging 2026-08-21 20:47:35 +02:00
AsamK
d2f5696df1 Update libsignal-service 2026-08-21 20:35:42 +02:00
AsamK
81e513d6b7 Implement storage sync loop detector 2026-08-21 18:46:03 +02:00
AsamK
dbf11fd006 Update graalvm build tools 2026-08-21 18:37:35 +02:00
Bazyli Brzóska
7e802166a3
fix(json): expose data message metadata (#2104) 2026-08-17 12:40:27 +02:00
Bazyli Brzóska
0f88410465
feat(json): expose voice-note attachment metadata (#2100)
Include isVoiceNote in receive JSON and JSON-RPC attachment payloads. Add regression coverage for true and false serialization.
2026-08-10 10:38:27 +02:00
Javair Ratliff
13819c8f09
Support the GroupsV2 "end group" (terminate) feature (#2098)
* Show and enforce the terminated state of a group

A group admin can permanently terminate a group. Afterwards the group is
read-only for everyone, not even an admin can send messages or start calls;
members keep access to the existing history. Other clients silently drop
messages sent to a terminated group.

Read the DecryptedGroup.terminated flag through GroupInfo.isTerminated() and:

- surface it in listGroups (json and plain text) and as the DBus IsTerminated
  group property
- refuse to send to a terminated group locally (messages, reactions, typing and
  group stories) rather than send one that other clients ignore
- drop incoming messages addressed to a terminated group

* Add terminateGroup command to terminate a group for everyone

Let a group admin permanently terminate a GroupV2 group via the
TerminateGroupAction (Groups.proto field 28, change epoch 7) the pinned library
already exposes as GroupsV2Operations.createTerminateGroup().

- GroupV2Helper.terminateGroup builds and commits the change
- GroupHelper.terminateGroup resolves/refreshes the group (v2 only), sends the
  update to members and syncs storage, with the same conflict-retry as
  updateGroup
- exposed through Manager.terminateGroup, the terminateGroup CLI/JSON-RPC command
  and the DBus Group.terminateGroup method

---------

Co-authored-by: FailSpy <FailSpy@users.noreply.github.com>
2026-08-08 17:16:49 +02:00
AsamK
85580d1ff6 Fix graalvm issue with image dimension probing
Fixes #2096
2026-08-04 13:16:58 +02:00
AsamK
f8ea8ed314 Update graalvm build tools 2026-08-04 13:14:29 +02:00
AsamK
472784e614 Replace unmaintained release actions 2026-08-02 08:45:09 +02:00
AsamK
db97731f63 Update redhat-actions in workflow 2026-08-02 08:45:09 +02:00
AsamK
ff9f2028ae Prepare next release 2026-08-02 08:45:09 +02:00
AsamK
9ee5262876 Prepare next release 2026-08-01 10:07:44 +02:00
58 changed files with 1337 additions and 160 deletions

View File

@ -35,55 +35,19 @@ jobs:
mv ./signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/* . mv ./signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/* .
echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Create release - name: Create draft release and upload assets
id: create_release
uses: actions/create-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: run: |
tag_name: v${{ steps.version.outputs.version }} # note: added `v` gh release create "v${{ steps.version.outputs.version }}" \
release_name: v${{ steps.version.outputs.version }} # note: added `v` --repo "${{ github.repository }}" \
draft: true --title "v${{ steps.version.outputs.version }}" \
--draft \
- name: Upload archive --verify-tag \
uses: actions/upload-release-asset@v1 "signal-cli-${{ steps.version.outputs.version }}.tar.gz" \
env: "signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz" \
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} "signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz" \
with: "signal-cli-${{ steps.version.outputs.version }}-json-schemas.tar.gz"
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.version.outputs.version }}.tar.gz
asset_name: signal-cli-${{ steps.version.outputs.version }}.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
- name: Upload Linux native archive
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz
asset_name: signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
- name: Upload Linux client archive
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz
asset_name: signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
- name: Upload JSON schemas archive
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.version.outputs.version }}-json-schemas.tar.gz
asset_name: signal-cli-${{ steps.version.outputs.version }}-json-schemas.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
build-container: build-container:
needs: release needs: release
@ -104,7 +68,7 @@ jobs:
- name: Build Image - name: Build Image
id: build_image id: build_image
uses: redhat-actions/buildah-build@v2 uses: redhat-actions/buildah-build@v3
with: with:
image: ${{ env.IMAGE_NAME }} image: ${{ env.IMAGE_NAME }}
tags: latest ${{ github.sha }} ${{ needs.release.outputs.version }} tags: latest ${{ github.sha }} ${{ needs.release.outputs.version }}
@ -112,7 +76,7 @@ jobs:
oci: true oci: true
- name: Push To GHCR - name: Push To GHCR
uses: redhat-actions/push-to-registry@v2 uses: redhat-actions/push-to-registry@v3
id: push id: push
with: with:
image: ${{ steps.build_image.outputs.image }} image: ${{ steps.build_image.outputs.image }}
@ -145,7 +109,7 @@ jobs:
- name: Build Image - name: Build Image
id: build_image id: build_image
uses: redhat-actions/buildah-build@v2 uses: redhat-actions/buildah-build@v3
with: with:
image: ${{ env.IMAGE_NAME }} image: ${{ env.IMAGE_NAME }}
tags: latest-native ${{ github.sha }}-native ${{ needs.release.outputs.version }}-native tags: latest-native ${{ github.sha }}-native ${{ needs.release.outputs.version }}-native
@ -153,7 +117,7 @@ jobs:
oci: true oci: true
- name: Push To GHCR - name: Push To GHCR
uses: redhat-actions/push-to-registry@v2 uses: redhat-actions/push-to-registry@v3
id: push id: push
with: with:
image: ${{ steps.build_image.outputs.image }} image: ${{ steps.build_image.outputs.image }}
@ -186,7 +150,7 @@ jobs:
- name: Build Image - name: Build Image
id: build_image id: build_image
uses: redhat-actions/buildah-build@v2 uses: redhat-actions/buildah-build@v3
with: with:
image: ${{ env.IMAGE_NAME }} image: ${{ env.IMAGE_NAME }}
tags: latest-client ${{ github.sha }}-client ${{ needs.release.outputs.version }}-client tags: latest-client ${{ github.sha }}-client ${{ needs.release.outputs.version }}-client
@ -194,7 +158,7 @@ jobs:
oci: true oci: true
- name: Push To GHCR - name: Push To GHCR
uses: redhat-actions/push-to-registry@v2 uses: redhat-actions/push-to-registry@v3
id: push id: push
with: with:
image: ${{ steps.build_image.outputs.image }} image: ${{ steps.build_image.outputs.image }}

View File

@ -1,5 +1,7 @@
# Changelog # Changelog
## [Unreleased]
## [0.14.7] - 2026-08-01 ## [0.14.7] - 2026-08-01
### Added ### Added

View File

@ -5,12 +5,12 @@ plugins {
application application
eclipse eclipse
`check-lib-versions` `check-lib-versions`
id("org.graalvm.buildtools.native") version "1.1.6" id("org.graalvm.buildtools.native") version "1.1.9"
} }
allprojects { allprojects {
group = "org.asamk" group = "org.asamk"
version = "0.14.7" version = "0.14.8"
} }
java { java {

View File

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

View File

@ -184,6 +184,10 @@ public interface Manager extends Closeable {
void deleteGroup(GroupId groupId) throws IOException; void deleteGroup(GroupId groupId) throws IOException;
SendGroupMessageResults terminateGroup(
GroupId groupId
) throws IOException, GroupNotFoundException, NotAGroupMemberException;
Pair<GroupId, SendGroupMessageResults> createGroup( Pair<GroupId, SendGroupMessageResults> createGroup(
String name, String name,
Set<RecipientIdentifier.Single> members, Set<RecipientIdentifier.Single> members,

View File

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

View File

@ -22,7 +22,8 @@ public record Group(
GroupPermission permissionEditDetails, GroupPermission permissionEditDetails,
GroupPermission permissionSendMessage, GroupPermission permissionSendMessage,
boolean isMember, boolean isMember,
boolean isAdmin boolean isAdmin,
boolean isTerminated
) { ) {
public static Group from( public static Group from(
@ -59,6 +60,7 @@ public record Group(
groupInfo.getPermissionEditDetails(), groupInfo.getPermissionEditDetails(),
groupInfo.getPermissionSendMessage(), groupInfo.getPermissionSendMessage(),
groupInfo.isMember(selfRecipientId), groupInfo.isMember(selfRecipientId),
groupInfo.isAdmin(selfRecipientId)); groupInfo.isAdmin(selfRecipientId),
groupInfo.isTerminated());
} }
} }

View File

@ -739,7 +739,10 @@ public record MessageEnvelope(
null, null,
d.getE164(), d.getE164(),
null)) 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 attachmentBackfill = !isPrimaryDevice;
final var spqr = true; final var spqr = true;
final var usernameSyncChangeMessage = !isPrimaryDevice; 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( public static ServiceEnvironmentConfig getServiceEnvironmentConfig(

View File

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

View File

@ -85,12 +85,18 @@ public class ContactHelper {
} }
public void setContactBlocked(RecipientId recipientId, boolean blocked) { 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); var contact = account.getContactStore().getContact(recipientId);
final var builder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact); final var builder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact);
if (blocked) { if (blocked) {
builder.withIsProfileSharingEnabled(false); 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) { public void setContactProfileSharing(RecipientId recipientId, boolean profileSharing) {

View File

@ -363,6 +363,28 @@ public class GroupHelper {
return results; return results;
} }
public SendGroupMessageResults terminateGroup(final GroupId groupId) throws IOException, GroupNotFoundException, NotAGroupMemberException {
final var group = getGroupForUpdating(groupId);
if (!(group instanceof GroupInfoV2)) {
throw new IOException("Terminating a group is only supported for Signal group v2 groups.");
}
SendGroupMessageResults results;
try {
results = terminateGroupV2((GroupInfoV2) group);
} catch (ConflictException e) {
// Detected conflicting update, refreshing group and trying again
results = terminateGroupV2((GroupInfoV2) getGroup(groupId, true));
}
context.getJobExecutor().enqueueJob(new SyncStorageJob());
return results;
}
private SendGroupMessageResults terminateGroupV2(final GroupInfoV2 group) throws IOException {
final var groupGroupChangePair = context.getGroupV2Helper().terminateGroup(group);
return sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
}
public void updateGroupProfileKey(GroupIdV2 groupId) throws GroupNotFoundException, NotAGroupMemberException, IOException { public void updateGroupProfileKey(GroupIdV2 groupId) throws GroupNotFoundException, NotAGroupMemberException, IOException {
var group = getGroupForUpdating(groupId); var group = getGroupForUpdating(groupId);
@ -437,12 +459,21 @@ public class GroupHelper {
} }
public void setGroupBlocked(final GroupId groupId, final boolean blocked) throws GroupNotFoundException { 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); var group = getGroup(groupId);
if (group == null) { if (group == null) {
throw new GroupNotFoundException(groupId); throw new GroupNotFoundException(groupId);
} }
group.setBlocked(blocked); group.setBlocked(blocked);
group.setBlockedAt(blocked ? blockedAt : 0);
account.getGroupStore().updateGroup(group); account.getGroupStore().updateGroup(group);
context.getJobExecutor().enqueueJob(new SyncStorageJob()); context.getJobExecutor().enqueueJob(new SyncStorageJob());
} }

View File

@ -543,6 +543,14 @@ class GroupV2Helper {
return commitChange(groupInfoV2, change); return commitChange(groupInfoV2, change);
} }
Pair<DecryptedGroup, GroupChangeResponse> terminateGroup(
GroupInfoV2 groupInfoV2
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var change = groupOperations.createTerminateGroup();
return commitChange(groupInfoV2, change);
}
Pair<DecryptedGroup, GroupChangeResponse> setMemberLabels( Pair<DecryptedGroup, GroupChangeResponse> setMemberLabels(
GroupInfoV2 groupInfoV2, GroupInfoV2 groupInfoV2,
String labelEmoji, String labelEmoji,

View File

@ -626,13 +626,12 @@ public final class IncomingMessageHandler {
for (var individual : blockedListMessage.individuals) { for (var individual : blockedListMessage.individuals) {
final var address = new RecipientAddress(individual.getAci(), individual.getE164()); final var address = new RecipientAddress(individual.getAci(), individual.getE164());
final var recipientId = account.getRecipientResolver().resolveRecipient(address); 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() for (var group : blockedListMessage.groups) {
.map(GroupId::unknownVersion) final var groupId = GroupId.unknownVersion(group.getGroupId());
.collect(Collectors.toSet())) {
try { try {
context.getGroupHelper().setGroupBlocked(groupId, true); context.getGroupHelper().setGroupBlocked(groupId, true, group.getBlockedAt());
} catch (GroupNotFoundException e) { } catch (GroupNotFoundException e) {
logger.warn("BlockedListMessage contained groupID that was not found in GroupStore: {}", logger.warn("BlockedListMessage contained groupID that was not found in GroupStore: {}",
groupId.toBase64()); groupId.toBase64());
@ -837,6 +836,15 @@ public final class IncomingMessageHandler {
return true; return true;
} }
if (group.isTerminated()) {
return message == null
|| message.getBody().isPresent()
|| message.getAttachments().isPresent()
|| message.getQuote().isPresent()
|| message.getPreviews().isPresent()
|| message.getMentions().isPresent()
|| message.getSticker().isPresent();
}
if (group.isAnnouncementGroup() && !group.isAdmin(recipientId)) { if (group.isAnnouncementGroup() && !group.isAdmin(recipientId)) {
return message == null return message == null
|| message.getBody().isPresent() || message.getBody().isPresent()

View File

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

View File

@ -567,7 +567,7 @@ public class SendHelper {
return results; return results;
} }
private GroupInfo getGroupForSending(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException { private GroupInfo getGroupForSending(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException, GroupSendingNotAllowedException {
var g = context.getGroupHelper().getGroup(groupId); var g = context.getGroupHelper().getGroup(groupId);
if (g == null) { if (g == null) {
throw new GroupNotFoundException(groupId); throw new GroupNotFoundException(groupId);
@ -575,6 +575,10 @@ public class SendHelper {
if (!g.isMember(account.getSelfRecipientId())) { if (!g.isMember(account.getSelfRecipientId())) {
throw new NotAGroupMemberException(groupId, g.getTitle()); throw new NotAGroupMemberException(groupId, g.getTitle());
} }
if (g.isTerminated()) {
// Other clients drop messages sent to a terminated group.
throw new GroupSendingNotAllowedException(groupId, g.getTitle());
}
if (!g.isProfileSharingEnabled()) { if (!g.isProfileSharingEnabled()) {
g.setProfileSharingEnabled(true); g.setProfileSharingEnabled(true);
account.getGroupStore().updateGroup(g); account.getGroupStore().updateGroup(g);

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.GroupV1RecordProcessor;
import org.asamk.signal.manager.syncStorage.GroupV2RecordProcessor; import org.asamk.signal.manager.syncStorage.GroupV2RecordProcessor;
import org.asamk.signal.manager.syncStorage.StickerPackRecordProcessor; 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.StorageSyncModels;
import org.asamk.signal.manager.syncStorage.StorageSyncValidations; import org.asamk.signal.manager.syncStorage.StorageSyncValidations;
import org.asamk.signal.manager.syncStorage.WriteOperationResult; import org.asamk.signal.manager.syncStorage.WriteOperationResult;
@ -61,11 +62,13 @@ public class StorageHelper {
private final SignalAccount account; private final SignalAccount account;
private final SignalDependencies dependencies; private final SignalDependencies dependencies;
private final Context context; private final Context context;
private final StorageSyncLoopDetector storageSyncLoopDetector;
public StorageHelper(final Context context) { public StorageHelper(final Context context) {
this.account = context.getAccount(); this.account = context.getAccount();
this.dependencies = context.getDependencies(); this.dependencies = context.getDependencies();
this.context = context; this.context = context;
this.storageSyncLoopDetector = new StorageSyncLoopDetector(account::isMultiDevice);
} }
public void syncDataWithStorage() throws IOException { public void syncDataWithStorage() throws IOException {
@ -84,6 +87,7 @@ public class StorageHelper {
final var storageServiceRepository = dependencies.getStorageServiceRepository(); final var storageServiceRepository = dependencies.getStorageServiceRepository();
final var result = storageServiceRepository.getStorageManifestIfDifferentVersion(storageKey, final var result = storageServiceRepository.getStorageManifestIfDifferentVersion(storageKey,
localManifestVersion); localManifestVersion);
final var fetchedRemoteManifest = result instanceof ManifestIfDifferentVersionResult.DifferentVersion;
var needsForcePush = false; var needsForcePush = false;
final var remoteManifest = switch (result) { final var remoteManifest = switch (result) {
@ -146,7 +150,10 @@ public class StorageHelper {
needsForcePush = true; needsForcePush = true;
} else { } else {
try { try {
needsMultiDeviceSync = writeToStorage(storageKey, remoteManifest, needsForcePush); needsMultiDeviceSync = writeToStorage(storageKey,
remoteManifest,
needsForcePush,
fetchedRemoteManifest);
} catch (RetryLaterException e) { } catch (RetryLaterException e) {
// TODO retry later // TODO retry later
return; return;
@ -288,7 +295,8 @@ public class StorageHelper {
private boolean writeToStorage( private boolean writeToStorage(
final StorageKey storageKey, final StorageKey storageKey,
final SignalStorageManifest remoteManifest, final SignalStorageManifest remoteManifest,
final boolean needsForcePush final boolean needsForcePush,
final boolean fetchedRemoteManifest
) throws IOException, RetryLaterException { ) throws IOException, RetryLaterException {
final WriteOperationResult remoteWriteOperation; final WriteOperationResult remoteWriteOperation;
try (final var connection = account.getAccountDatabase().getConnection()) { try (final var connection = account.getAccountDatabase().getConnection()) {
@ -326,6 +334,19 @@ public class StorageHelper {
if (remoteWriteOperation.isEmpty()) { if (remoteWriteOperation.isEmpty()) {
logger.debug("No remote writes needed. Still at version: {}", remoteManifest.version); 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; return false;
} }
@ -344,11 +365,18 @@ public class StorageHelper {
remoteWriteOperation.deletes()); remoteWriteOperation.deletes());
switch (result) { switch (result) {
case WriteStorageRecordsResult.ConflictError ignored -> { case WriteStorageRecordsResult.ConflictError ignored -> {
storageSyncLoopDetector.onWriteFailed();
logger.debug("Hit a conflict when trying to resolve the conflict! Retrying."); logger.debug("Hit a conflict when trying to resolve the conflict! Retrying.");
throw new RetryLaterException(); throw new RetryLaterException();
} }
case WriteStorageRecordsResult.NetworkError networkError -> throw networkError.getException(); case WriteStorageRecordsResult.NetworkError networkError -> {
case WriteStorageRecordsResult.StatusCodeError statusCodeError -> throw statusCodeError.getException(); storageSyncLoopDetector.onWriteFailed();
throw networkError.getException();
}
case WriteStorageRecordsResult.StatusCodeError statusCodeError -> {
storageSyncLoopDetector.onWriteFailed();
throw statusCodeError.getException();
}
case WriteStorageRecordsResult.Success ignored -> { case WriteStorageRecordsResult.Success ignored -> {
logger.debug("Saved new manifest. Now at version: {}", remoteWriteOperation.manifest().version); logger.debug("Saved new manifest. Now at version: {}", remoteWriteOperation.manifest().version);
storeManifestLocally(remoteWriteOperation.manifest()); storeManifestLocally(remoteWriteOperation.manifest());
@ -424,7 +452,7 @@ public class StorageHelper {
final var stickerPacks = account.getStickerStore() final var stickerPacks = account.getStickerStore()
.getStickerPacks(connection) .getStickerPacks(connection)
.stream() .stream()
.filter(pack -> pack.isInstalled() || pack.deletedTimestamp() > 0) .filter(pack -> pack.storageId() != null)
.toList(); .toList();
newStickerPackStorageIds = generateStickerPackStorageIds(stickerPacks); newStickerPackStorageIds = generateStickerPackStorageIds(stickerPacks);
for (final var stickerPack : stickerPacks) { for (final var stickerPack : stickerPacks) {
@ -688,6 +716,13 @@ public class StorageHelper {
final var contactRecordProcessor = new ContactRecordProcessor(account, connection, context.getJobExecutor()); final var contactRecordProcessor = new ContactRecordProcessor(account, connection, context.getJobExecutor());
final var stickerPackRecordProcessor = new StickerPackRecordProcessor(account, connection); 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) { for (final var record : records) {
if (record.getProto().account != null) { if (record.getProto().account != null) {
logger.debug("Reading record {} of type account", record.getId()); 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()); final var address = account.getRecipientAddressResolver().resolveRecipientAddress(record.first());
if (address.aci().isPresent() || address.number().isPresent()) { if (address.aci().isPresent() || address.number().isPresent()) {
addresses.add(new BlockedListMessage.Individual(address.aci().orElse(null), 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()) { for (var record : account.getGroupStore().getGroups()) {
if (record.isBlocked()) { if (record.isBlocked()) {
groupIds.add(record.getGroupId().serialize()); groups.add(new BlockedListMessage.Group(record.getGroupId().serialize(), record.getBlockedAt()));
} }
} }
return context.getSendHelper() return context.getSendHelper()
.sendSyncMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(addresses, groupIds))); .sendSyncMessage(SignalServiceSyncMessage.forBlocked(new BlockedListMessage(addresses, groups)));
} }
public SendMessageResult sendVerifiedMessage( public SendMessageResult sendVerifiedMessage(

View File

@ -610,6 +610,11 @@ public class ManagerImpl implements Manager {
context.getGroupHelper().deleteGroup(groupId); context.getGroupHelper().deleteGroup(groupId);
} }
@Override
public SendGroupMessageResults terminateGroup(GroupId groupId) throws IOException, GroupNotFoundException, NotAGroupMemberException {
return context.getGroupHelper().terminateGroup(groupId);
}
@Override @Override
public Pair<GroupId, SendGroupMessageResults> createGroup( public Pair<GroupId, SendGroupMessageResults> createGroup(
String name, String name,
@ -912,6 +917,10 @@ public class ManagerImpl implements Manager {
if (!(groupInfo instanceof GroupInfoV2 groupInfoV2)) { if (!(groupInfo instanceof GroupInfoV2 groupInfoV2)) {
throw new IOException("Stories are only supported for V2 groups"); throw new IOException("Stories are only supported for V2 groups");
} }
if (groupInfoV2.isTerminated()) {
// Other clients drop messages sent to a terminated group.
throw new IOException("Cannot send a story to a group that has been terminated");
}
final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment); final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment);
final var groupContext = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey()) final var groupContext = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey())

View File

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

View File

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

View File

@ -58,6 +58,10 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
public abstract void setBlocked(boolean blocked); public abstract void setBlocked(boolean blocked);
public abstract long getBlockedAt();
public abstract void setBlockedAt(long blockedAt);
public abstract boolean isProfileSharingEnabled(); public abstract boolean isProfileSharingEnabled();
public abstract void setProfileSharingEnabled(boolean profileSharingEnabled); public abstract void setProfileSharingEnabled(boolean profileSharingEnabled);
@ -66,6 +70,8 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
public abstract boolean isAnnouncementGroup(); public abstract boolean isAnnouncementGroup();
public abstract boolean isTerminated();
public abstract GroupPermission getPermissionAddMember(); public abstract GroupPermission getPermissionAddMember();
public abstract GroupPermission getPermissionEditDetails(); public abstract GroupPermission getPermissionEditDetails();

View File

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

View File

@ -23,6 +23,7 @@ public final class GroupInfoV2 extends GroupInfo {
private final GroupMasterKey masterKey; private final GroupMasterKey masterKey;
private final DistributionId distributionId; private final DistributionId distributionId;
private boolean blocked; private boolean blocked;
private long blockedAt;
private boolean profileSharingEnabled; private boolean profileSharingEnabled;
private DecryptedGroup group; private DecryptedGroup group;
private byte[] storageRecord; private byte[] storageRecord;
@ -47,6 +48,7 @@ public final class GroupInfoV2 extends GroupInfo {
final DecryptedGroup group, final DecryptedGroup group,
final DistributionId distributionId, final DistributionId distributionId,
final boolean blocked, final boolean blocked,
final long blockedAt,
final boolean profileSharingEnabled, final boolean profileSharingEnabled,
final boolean permissionDenied, final boolean permissionDenied,
final byte[] storageRecord, final byte[] storageRecord,
@ -57,6 +59,7 @@ public final class GroupInfoV2 extends GroupInfo {
this.group = group; this.group = group;
this.distributionId = distributionId; this.distributionId = distributionId;
this.blocked = blocked; this.blocked = blocked;
this.blockedAt = blockedAt;
this.profileSharingEnabled = profileSharingEnabled; this.profileSharingEnabled = profileSharingEnabled;
this.permissionDenied = permissionDenied; this.permissionDenied = permissionDenied;
this.storageRecord = storageRecord; this.storageRecord = storageRecord;
@ -186,9 +189,24 @@ public final class GroupInfoV2 extends GroupInfo {
@Override @Override
public void setBlocked(final boolean blocked) { public void setBlocked(final boolean blocked) {
if (blocked && !this.blocked) {
blockedAt = System.currentTimeMillis();
} else if (!blocked) {
blockedAt = 0;
}
this.blocked = blocked; this.blocked = blocked;
} }
@Override
public long getBlockedAt() {
return blockedAt;
}
@Override
public void setBlockedAt(final long blockedAt) {
this.blockedAt = blockedAt;
}
@Override @Override
public boolean isProfileSharingEnabled() { public boolean isProfileSharingEnabled() {
return profileSharingEnabled; return profileSharingEnabled;
@ -211,6 +229,11 @@ public final class GroupInfoV2 extends GroupInfo {
return this.group != null && this.group.isAnnouncementGroup == EnabledState.ENABLED; return this.group != null && this.group.isAnnouncementGroup == EnabledState.ENABLED;
} }
@Override
public boolean isTerminated() {
return this.group != null && Boolean.TRUE.equals(this.group.terminated);
}
@Override @Override
public GroupPermission getPermissionAddMember() { public GroupPermission getPermissionAddMember() {
final var accessControl = getAccessControl(); final var accessControl = getAccessControl();

View File

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

View File

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

View File

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

View File

@ -96,6 +96,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
expiration_time_version INTEGER DEFAULT 1 NOT NULL, expiration_time_version INTEGER DEFAULT 1 NOT NULL,
mute_until INTEGER NOT NULL DEFAULT 0, mute_until INTEGER NOT NULL DEFAULT 0,
blocked INTEGER NOT NULL DEFAULT FALSE, blocked INTEGER NOT NULL DEFAULT FALSE,
blocked_at INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT FALSE, archived INTEGER NOT NULL DEFAULT FALSE,
profile_sharing INTEGER NOT NULL DEFAULT FALSE, profile_sharing INTEGER NOT NULL DEFAULT FALSE,
hide_story 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() { public List<Pair<RecipientId, Contact>> getContacts() {
final var sql = ( 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 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 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.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential, r.profile_key, r.profile_key_credential,
r.pni_signature_verified, 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.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.discoverable,
r.storage_record r.storage_record
@ -397,7 +398,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
r.number, r.aci, r.pni, r.username, r.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential, r.profile_key, r.profile_key_credential,
r.pni_signature_verified, 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.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.discoverable,
r.storage_record r.storage_record
@ -447,7 +448,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
r.number, r.aci, r.pni, r.username, r.number, r.aci, r.pni, r.username,
r.profile_key, r.profile_key_credential, r.profile_key, r.profile_key_credential,
r.pni_signature_verified, 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.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.discoverable,
r.storage_record r.storage_record
@ -892,7 +893,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
final var sql = ( final var sql = (
""" """
UPDATE %s 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 = ? WHERE _id = ?
""" """
).formatted(TABLE_RECIPIENT); ).formatted(TABLE_RECIPIENT);
@ -907,25 +908,49 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
statement.setBoolean(8, contact != null && contact.isProfileSharingEnabled()); statement.setBoolean(8, contact != null && contact.isProfileSharingEnabled());
statement.setString(9, contact == null ? null : contact.color()); statement.setString(9, contact == null ? null : contact.color());
statement.setBoolean(10, contact != null && contact.isBlocked()); 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) { if (contact == null || contact.unregisteredTimestamp() == null) {
statement.setNull(12, Types.INTEGER); statement.setNull(13, Types.INTEGER);
} else { } 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.nickNameGivenName());
statement.setString(14, contact == null ? null : contact.nickNameFamilyName()); statement.setString(15, contact == null ? null : contact.nickNameFamilyName());
statement.setString(15, contact == null ? null : contact.note()); statement.setString(16, contact == null ? null : contact.note());
statement.setBoolean(16, contact != null && contact.isHidden()); statement.setBoolean(17, contact != null && contact.isHidden());
statement.setLong(17, recipientId.id()); statement.setLong(18, recipientId.id());
statement.executeUpdate(); statement.executeUpdate();
} }
if (contact != null && contact.unregisteredTimestamp() != null) { if (contact != null && contact.unregisteredTimestamp() != null) {
markUnregisteredAndSplitIfNecessary(connection, recipientId); markUnregisteredAndSplitIfNecessary(connection, recipientId, contact.unregisteredTimestamp());
} }
rotateStorageId(connection, recipientId); 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( public int removeStorageIdsFromLocalOnlyUnregisteredRecipients(
final Connection connection, final Connection connection,
final Collection<StorageId> storageIds final Collection<StorageId> storageIds
@ -1061,7 +1086,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
if (recipientAddress.get().address().aci().isEmpty() || ( if (recipientAddress.get().address().aci().isEmpty() || (
contact != null && contact.unregisteredTimestamp() != null 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) { if (registered) {
markRegistered(connection, recipientId); markRegistered(connection, recipientId);
} else { } else {
markUnregisteredAndSplitIfNecessary(connection, recipientId); markUnregisteredAndSplitIfNecessary(connection, recipientId, System.currentTimeMillis());
} }
connection.commit(); connection.commit();
} catch (SQLException e) { } catch (SQLException e) {
@ -1105,9 +1130,10 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
private void markUnregisteredAndSplitIfNecessary( private void markUnregisteredAndSplitIfNecessary(
final Connection connection, final Connection connection,
final RecipientId recipientId final RecipientId recipientId,
final long unregisteredTimestamp
) throws SQLException { ) throws SQLException {
markUnregistered(connection, recipientId); markUnregistered(connection, recipientId, unregisteredTimestamp);
final var address = resolveRecipientAddress(connection, recipientId); final var address = resolveRecipientAddress(connection, recipientId);
final var needSplit = address.aci().isPresent() && address.pni().isPresent(); final var needSplit = address.aci().isPresent() && address.pni().isPresent();
logger.trace("Marking unregistered recipient {} as unregistered (and split={}): {}", 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 = ( final var sql = (
""" """
UPDATE %s UPDATE %s
@ -1163,7 +1193,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
""" """
).formatted(TABLE_RECIPIENT); ).formatted(TABLE_RECIPIENT);
try (final var statement = connection.prepareStatement(sql)) { try (final var statement = connection.prepareStatement(sql)) {
statement.setLong(1, System.currentTimeMillis()); statement.setLong(1, unregisteredTimestamp);
statement.setLong(2, recipientId.id()); statement.setLong(2, recipientId.id());
statement.executeUpdate(); statement.executeUpdate();
} }
@ -1488,9 +1518,14 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
RecipientId toBeMergedRecipientId RecipientId toBeMergedRecipientId
) throws SQLException { ) throws SQLException {
final var contact = getContact(connection, recipientId); final var contact = getContact(connection, recipientId);
final var toBeMergedContact = getContact(connection, toBeMergedRecipientId);
if (contact == null) { if (contact == null) {
final var toBeMergedContact = getContact(connection, toBeMergedRecipientId);
storeContact(connection, recipientId, toBeMergedContact); 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); final var profileKey = getProfileKey(connection, recipientId);
@ -1515,6 +1550,24 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
recipientsMerged.put(toBeMergedRecipientId.id(), recipientId.id()); 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( private Optional<RecipientWithAddress> findByNumber(
final Connection connection, final Connection connection,
final String number 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 { private Contact getContact(final Connection connection, final RecipientId recipientId) throws SQLException {
final var sql = ( 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 FROM %s r
WHERE r._id = ? AND (%s) WHERE r._id = ? AND (%s)
""" """
@ -1699,6 +1752,7 @@ public class RecipientStore implements RecipientIdCreator, RecipientResolver, Re
resultSet.getLong("mute_until"), resultSet.getLong("mute_until"),
resultSet.getBoolean("hide_story"), resultSet.getBoolean("hide_story"),
resultSet.getBoolean("blocked"), resultSet.getBoolean("blocked"),
resultSet.getLong("blocked_at"),
resultSet.getBoolean("archived"), resultSet.getBoolean("archived"),
resultSet.getBoolean("profile_sharing"), resultSet.getBoolean("profile_sharing"),
resultSet.getBoolean("hidden"), resultSet.getBoolean("hidden"),

View File

@ -410,7 +410,7 @@ public class SessionStore implements SignalServiceSessionStore {
} }
private static boolean isActive(SessionRecord record) { private static boolean isActive(SessionRecord record) {
return record != null && record.hasSenderChain(0.0); return record != null && record.hasSenderChain();
} }
record Key(String address, int deviceId) {} record Key(String address, int deviceId) {}

View File

@ -298,7 +298,7 @@ public class StickerStore {
""" """
SELECT s.pack_id SELECT s.pack_id
FROM %s s 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); ).formatted(TABLE_STICKER);
final var updateSql = ( 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.Connection;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -56,6 +57,29 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
this.selfNumber = account.getNumber(); 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: * Error cases:
* - You can't have a contact record without an ACI or PNI. * - You can't have a contact record without an ACI or PNI.
@ -205,6 +229,7 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
.identityState(identityState) .identityState(identityState)
.identityKey(identityKey) .identityKey(identityKey)
.blocked(remote.blocked) .blocked(remote.blocked)
.blockedAtTimestamp(remote.blockedAtTimestamp)
.whitelisted(remote.whitelisted) .whitelisted(remote.whitelisted)
.archived(remote.archived) .archived(remote.archived)
.markedUnread(remote.markedUnread) .markedUnread(remote.markedUnread)
@ -283,7 +308,9 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
final var contactNickGivenName = contact == null ? null : contact.nickNameGivenName(); final var contactNickGivenName = contact == null ? null : contact.nickNameGivenName();
final var contactNickFamilyName = contact == null ? null : contact.nickNameFamilyName(); final var contactNickFamilyName = contact == null ? null : contact.nickNameFamilyName();
final var contactNote = contact == null ? null : contact.note(); final var contactNote = contact == null ? null : contact.note();
final var blockedAt = contact == null ? 0 : contact.blockedAt();
if (blocked != contactProto.blocked if (blocked != contactProto.blocked
|| blockedAt != contactProto.blockedAtTimestamp
|| profileShared != contactProto.whitelisted || profileShared != contactProto.whitelisted
|| archived != contactProto.archived || archived != contactProto.archived
|| hidden != contactProto.hidden || hidden != contactProto.hidden
@ -301,6 +328,7 @@ public class ContactRecordProcessor extends DefaultStorageRecordProcessor<Signal
logger.debug("Storing new or updated contact {}", recipientId); logger.debug("Storing new or updated contact {}", recipientId);
final var contactBuilder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact); final var contactBuilder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact);
final var newContact = contactBuilder.withIsBlocked(contactProto.blocked) final var newContact = contactBuilder.withIsBlocked(contactProto.blocked)
.withBlockedAt(contactProto.blocked ? contactProto.blockedAtTimestamp : 0)
.withIsProfileSharingEnabled(contactProto.whitelisted) .withIsProfileSharingEnabled(contactProto.whitelisted)
.withIsArchived(contactProto.archived) .withIsArchived(contactProto.archived)
.withIsHidden(contactProto.hidden) .withIsHidden(contactProto.hidden)

View File

@ -114,6 +114,7 @@ public final class GroupV1RecordProcessor extends DefaultStorageRecordProcessor<
final var group = account.getGroupStore().getOrCreateGroupV1(connection, groupIdV1); final var group = account.getGroupStore().getOrCreateGroupV1(connection, groupIdV1);
if (group != null) { if (group != null) {
group.setBlocked(groupV1Proto.blocked); group.setBlocked(groupV1Proto.blocked);
group.setBlockedAt(0);
account.getGroupStore().updateGroup(connection, group); account.getGroupStore().updateGroup(connection, group);
account.getGroupStore() account.getGroupStore()
.storeStorageRecord(connection, group.getGroupId(), groupV1Record.getId(), groupV1Proto.encode()); .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() final var mergedBuilder = remote.newBuilder()
.masterKey(remote.masterKey) .masterKey(remote.masterKey)
.blocked(remote.blocked) .blocked(remote.blocked)
.blockedAtTimestamp(remote.blockedAtTimestamp)
.whitelisted(remote.whitelisted) .whitelisted(remote.whitelisted)
.archived(remote.archived) .archived(remote.archived)
.markedUnread(remote.markedUnread) .markedUnread(remote.markedUnread)
@ -93,6 +94,7 @@ public final class GroupV2RecordProcessor extends DefaultStorageRecordProcessor<
final var group = account.getGroupStore().getGroupOrPartialMigrate(connection, groupMasterKey); final var group = account.getGroupStore().getGroupOrPartialMigrate(connection, groupMasterKey);
group.setBlocked(groupV2Proto.blocked); group.setBlocked(groupV2Proto.blocked);
group.setBlockedAt(groupV2Proto.blocked ? groupV2Proto.blockedAtTimestamp : 0);
group.setProfileSharingEnabled(groupV2Proto.whitelisted); group.setProfileSharingEnabled(groupV2Proto.whitelisted);
account.getGroupStore().updateGroup(connection, group); account.getGroupStore().updateGroup(connection, group);
account.getGroupStore() account.getGroupStore()

View File

@ -63,16 +63,17 @@ public class StickerPackRecordProcessor extends DefaultStorageRecordProcessor<Si
final var remote = remoteRecord.getProto(); final var remote = remoteRecord.getProto();
final var local = localRecord.getProto(); final var local = localRecord.getProto();
final var isRemoteDeleted = remote.deletedAtTimestamp > 0; if (shouldKeepLocalDeletion(remote.deletedAtTimestamp, local.deletedAtTimestamp)) {
final var isLocalDeleted = local.deletedAtTimestamp > 0;
if (isRemoteDeleted && isLocalDeleted && local.deletedAtTimestamp > remote.deletedAtTimestamp) {
return localRecord; return localRecord;
} }
return remoteRecord; return remoteRecord;
} }
static boolean shouldKeepLocalDeletion(final long remoteDeletedAt, final long localDeletedAt) {
return remoteDeletedAt > 0 && localDeletedAt > 0 && localDeletedAt < remoteDeletedAt;
}
@Override @Override
protected void insertLocal(final SignalStickerPackRecord record) throws SQLException { protected void insertLocal(final SignalStickerPackRecord record) throws SQLException {
account.getStickerStore().upsertFromStorageSync(connection, record); 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())) .nickname(getNicknameRemoteRecord(recipient.getContact()))
.note(emptyIfNull(recipient.getContact().note())) .note(emptyIfNull(recipient.getContact().note()))
.blocked(recipient.getContact().isBlocked()) .blocked(recipient.getContact().isBlocked())
.blockedAtTimestamp(recipient.getContact().blockedAt())
.whitelisted(recipient.getContact().isProfileSharingEnabled()) .whitelisted(recipient.getContact().isProfileSharingEnabled())
.mutedUntilTimestamp(recipient.getContact().muteUntil()) .mutedUntilTimestamp(recipient.getContact().muteUntil())
.hideStory(recipient.getContact().hideStory()) .hideStory(recipient.getContact().hideStory())
@ -161,6 +162,7 @@ public final class StorageSyncModels {
final var builder = SignalGroupV2Record.Companion.newBuilder(group.getStorageRecord()); final var builder = SignalGroupV2Record.Companion.newBuilder(group.getStorageRecord());
builder.masterKey(ByteString.of(group.getMasterKey().serialize())); builder.masterKey(ByteString.of(group.getMasterKey().serialize()));
builder.blocked(group.isBlocked()); builder.blocked(group.isBlocked());
builder.blockedAtTimestamp(group.getBlockedAt());
builder.whitelisted(group.isProfileSharingEnabled()); builder.whitelisted(group.isProfileSharingEnabled());
return builder.build(); return builder.build();
} }

View File

@ -79,7 +79,7 @@ public class AttachmentUtils {
height = image.getHeight(); height = image.getHeight();
} }
fis.getChannel().position(0); fis.getChannel().position(0);
} catch (IOException e) { } catch (IOException | LinkageError e) {
logger.debug("Failed to probe image dimensions, sending without width/height: {}", e.getMessage()); logger.debug("Failed to probe image dimensions, sending without width/height: {}", e.getMessage());
fis.getChannel().position(0); fis.getChannel().position(0);
} }
@ -93,7 +93,7 @@ public class AttachmentUtils {
width = image.getWidth(); width = image.getWidth();
height = image.getHeight(); height = image.getHeight();
} }
} catch (IOException e) { } catch (IOException | LinkageError e) {
logger.debug("Failed to probe image dimensions, sending without width/height: {}", e.getMessage()); logger.debug("Failed to probe image dimensions, sending without width/height: {}", e.getMessage());
} }
return new ProbedStream(new ByteArrayInputStream(bytes), width, height); return new ProbedStream(new ByteArrayInputStream(bytes), width, height);

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

@ -0,0 +1,104 @@
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.groups.GroupUtils;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.asamk.signal.manager.storage.recipients.TestRecipientId;
import org.junit.jupiter.api.Test;
import org.signal.core.models.ServiceId;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.whispersystems.signalservice.api.push.DistributionId;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class GroupInfoTerminatedTest {
private static final RecipientResolver UNUSED_RESOLVER = new RecipientResolver() {
@Override
public RecipientId resolveRecipient(final RecipientAddress address) {
throw new UnsupportedOperationException();
}
@Override
public RecipientId resolveRecipient(final long recipientId) {
throw new UnsupportedOperationException();
}
@Override
public RecipientId resolveRecipient(final String identifier) {
throw new UnsupportedOperationException();
}
@Override
public RecipientId resolveRecipient(final ServiceId serviceId) {
throw new UnsupportedOperationException();
}
};
private static GroupMasterKey masterKey() {
final var bytes = new byte[32];
for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte) (i + 1);
}
try {
return new GroupMasterKey(bytes);
} catch (InvalidInputException e) {
throw new AssertionError(e);
}
}
private static GroupInfoV2 groupV2(final DecryptedGroup group) {
final var masterKey = masterKey();
return new GroupInfoV2(GroupUtils.getGroupIdV2(masterKey),
masterKey,
group,
DistributionId.create(),
false,
0,
false,
false,
null,
UNUSED_RESOLVER);
}
@Test
void v1GroupsAreNeverTerminated() {
final var group = new GroupInfoV1(GroupId.v1(new byte[16]));
assertFalse(group.isTerminated());
}
@Test
void v2ReadsTerminatedFlagFromDecryptedGroup() {
assertTrue(groupV2(new DecryptedGroup.Builder().terminated(true).build()).isTerminated());
assertFalse(groupV2(new DecryptedGroup.Builder().terminated(false).build()).isTerminated());
assertFalse(groupV2(new DecryptedGroup.Builder().build()).isTerminated());
}
@Test
void v2IsNotTerminatedWhenGroupStateMissing() {
final var masterKey = masterKey();
final var group = new GroupInfoV2(GroupUtils.getGroupIdV2(masterKey), masterKey, UNUSED_RESOLVER);
assertFalse(group.isTerminated());
}
@Test
void groupApiRecordCarriesTerminatedFromModel() {
final RecipientId self = TestRecipientId.createTestId(1);
final var terminated = Group.from(groupV2(new DecryptedGroup.Builder().terminated(true).build()),
recipientId -> null,
self);
assertTrue(terminated.isTerminated());
final var live = Group.from(groupV2(new DecryptedGroup.Builder().terminated(false).build()),
recipientId -> null,
self);
assertFalse(live.isTerminated());
}
}

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

@ -452,6 +452,7 @@ Groups have the following (case-sensitive) properties:
* IsBlocked<b> : true=member will not receive group messages; false=not blocked * IsBlocked<b> : true=member will not receive group messages; false=not blocked
* IsMember<b> (read-only) : always true (object path exists only for group members) * IsMember<b> (read-only) : always true (object path exists only for group members)
* IsAdmin<b> (read-only) : true=member has admin privileges; false=not admin * IsAdmin<b> (read-only) : true=member has admin privileges; false=not admin
* IsTerminated<b> (read-only) : true=group was permanently ended by an admin; nobody (not even an admin) can send messages or start calls, sending is rejected locally
* MessageExpirationTimer<i> : int32 representing message expiration time for group * MessageExpirationTimer<i> : int32 representing message expiration time for group
* Members<as> (read-only) : String array of group members' phone numbers * Members<as> (read-only) : String array of group members' phone numbers
* PendingMembers<as> (read-only) : String array of pending members' phone numbers * PendingMembers<as> (read-only) : String array of pending members' phone numbers
@ -502,6 +503,11 @@ Exceptions: Failure
quitGroup() -> <>:: quitGroup() -> <>::
Exceptions: Failure, LastGroupAdmin Exceptions: Failure, LastGroupAdmin
terminateGroup() -> <>::
Permanently terminate the group for all members (requires admin privileges). Afterwards nobody can send messages or start calls; members keep access to the existing history.
Exceptions: Failure
removeAdmins(recipients<as>) -> <>:: removeAdmins(recipients<as>) -> <>::
* recipients : String array of phone numbers * recipients : String array of phone numbers

View File

@ -786,6 +786,16 @@ Specify the recipient group ID in base64 encoding.
*--delete*:: *--delete*::
Delete local group data completely after quitting group. Delete local group data completely after quitting group.
=== terminateGroup
Permanently terminate a group for all members.
This requires admin privileges.
Afterwards no member (not even an admin) can send messages or start calls in the group; members keep access to the existing message history.
Only supported for Signal group v2 groups.
*-g* GROUP, *--group-id* GROUP::
Specify the group ID in base64 encoding.
=== listGroups === listGroups
Show a list of known groups and related information. Show a list of known groups and related information.

View File

@ -595,6 +595,7 @@ public interface Signal extends DBusInterface {
@DBusProperty(name = "IsBlocked", type = Boolean.class) @DBusProperty(name = "IsBlocked", type = Boolean.class)
@DBusProperty(name = "IsMember", type = Boolean.class, access = DBusProperty.Access.READ) @DBusProperty(name = "IsMember", type = Boolean.class, access = DBusProperty.Access.READ)
@DBusProperty(name = "IsAdmin", type = Boolean.class, access = DBusProperty.Access.READ) @DBusProperty(name = "IsAdmin", type = Boolean.class, access = DBusProperty.Access.READ)
@DBusProperty(name = "IsTerminated", type = Boolean.class, access = DBusProperty.Access.READ)
@DBusProperty(name = "MessageExpirationTimer", type = Integer.class) @DBusProperty(name = "MessageExpirationTimer", type = Integer.class)
@DBusProperty(name = "Members", type = String[].class, access = DBusProperty.Access.READ) @DBusProperty(name = "Members", type = String[].class, access = DBusProperty.Access.READ)
@DBusProperty(name = "PendingMembers", type = String[].class, access = DBusProperty.Access.READ) @DBusProperty(name = "PendingMembers", type = String[].class, access = DBusProperty.Access.READ)
@ -611,6 +612,8 @@ public interface Signal extends DBusInterface {
void deleteGroup() throws Error.Failure; void deleteGroup() throws Error.Failure;
void terminateGroup() throws Error.Failure;
void addMembers(List<String> recipients) throws Error.Failure; void addMembers(List<String> recipients) throws Error.Failure;
void removeMembers(List<String> recipients) throws Error.Failure; void removeMembers(List<String> recipients) throws Error.Failure;

View File

@ -61,6 +61,7 @@ public class Commands {
addCommand(new SubmitRateLimitChallengeCommand()); addCommand(new SubmitRateLimitChallengeCommand());
addCommand(new StartChangeNumberCommand()); addCommand(new StartChangeNumberCommand());
addCommand(new StartLinkCommand()); addCommand(new StartLinkCommand());
addCommand(new TerminateGroupCommand());
addCommand(new TrustCommand()); addCommand(new TrustCommand());
addCommand(new UnblockCommand()); addCommand(new UnblockCommand());
addCommand(new UnregisterCommand()); addCommand(new UnregisterCommand());

View File

@ -79,12 +79,13 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
final var groupInviteLink = group.groupInviteLinkUrl(); final var groupInviteLink = group.groupInviteLinkUrl();
writer.println( writer.println(
"Id: {} Name: {} Description: {} Active: {} Blocked: {} Members: {} Pending members: {} Requesting members: {} Banned: {} Message expiration: {} Link: {}", "Id: {} Name: {} Description: {} Active: {} Blocked: {} Terminated: {} Members: {} Pending members: {} Requesting members: {} Banned: {} Message expiration: {} Link: {}",
group.groupId().toBase64(), group.groupId().toBase64(),
group.title(), group.title(),
group.description(), group.description(),
group.isMember(), group.isMember(),
group.isBlocked(), group.isBlocked(),
group.isTerminated(),
resolveMembers(group.members()), resolveMembers(group.members()),
resolveMemberAddress(group.pendingMembers()), resolveMemberAddress(group.pendingMembers()),
resolveMemberAddress(group.requestingMembers()), resolveMemberAddress(group.requestingMembers()),
@ -92,11 +93,12 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
group.messageExpirationTimer() == 0 ? "disabled" : group.messageExpirationTimer() + "s", group.messageExpirationTimer() == 0 ? "disabled" : group.messageExpirationTimer() + "s",
groupInviteLink == null ? '-' : groupInviteLink.getUrl()); groupInviteLink == null ? '-' : groupInviteLink.getUrl());
} else { } else {
writer.println("Id: {} Name: {} Active: {} Blocked: {}", writer.println("Id: {} Name: {} Active: {} Blocked: {} Terminated: {}",
group.groupId().toBase64(), group.groupId().toBase64(),
group.title(), group.title(),
group.isMember(), group.isMember(),
group.isBlocked()); group.isBlocked(),
group.isTerminated());
} }
} }
@ -133,7 +135,8 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
group.permissionAddMember().name(), group.permissionAddMember().name(),
group.permissionEditDetails().name(), group.permissionEditDetails().name(),
group.permissionSendMessage().name(), group.permissionSendMessage().name(),
groupInviteLink == null ? null : groupInviteLink.getUrl()); groupInviteLink == null ? null : groupInviteLink.getUrl(),
group.isTerminated());
}).toList(); }).toList();
jsonWriter.write(jsonGroups); jsonWriter.write(jsonGroups);
} }
@ -161,7 +164,8 @@ public class ListGroupsCommand implements JsonRpcLocalCommand {
String permissionAddMember, String permissionAddMember,
String permissionEditDetails, String permissionEditDetails,
String permissionSendMessage, String permissionSendMessage,
String groupInviteLink String groupInviteLink,
boolean isTerminated
) {} ) {}
private record JsonGroupMemberAddress(String number, String uuid) {} private record JsonGroupMemberAddress(String number, String uuid) {}

View File

@ -0,0 +1,54 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.IOErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.NotAGroupMemberException;
import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.util.CommandUtil;
import java.io.IOException;
import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
public class TerminateGroupCommand implements JsonRpcLocalCommand {
@Override
public String getName() {
return "terminateGroup";
}
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help(
"Permanently terminate a group for all members. Requires admin privileges; afterwards nobody can send messages or start calls.");
subparser.addArgument("-g", "--group-id", "--group").required(true).help("Specify the group ID.");
}
@Override
public void handleCommand(
final Namespace ns,
final Manager m,
final OutputWriter outputWriter
) throws CommandException {
final var groupId = CommandUtil.getGroupId(ns.getString("group-id"));
try {
final var results = m.terminateGroup(groupId);
outputResult(outputWriter, results);
} catch (IOException e) {
throw new IOErrorException("Failed to send message: "
+ e.getMessage()
+ " ("
+ e.getClass().getSimpleName()
+ ")", e);
} catch (GroupNotFoundException | NotAGroupMemberException e) {
throw new UserErrorException("Failed to terminate group: " + e.getMessage());
}
}
}

View File

@ -327,6 +327,21 @@ public class DbusManagerImpl implements Manager {
group.deleteGroup(); group.deleteGroup();
} }
@Override
public SendGroupMessageResults terminateGroup(
final GroupId groupId
) throws IOException, GroupNotFoundException, NotAGroupMemberException {
final var group = getRemoteObject(signal.getGroup(groupId.serialize()), Signal.Group.class);
try {
group.terminateGroup();
} catch (Signal.Error.GroupNotFound e) {
throw new GroupNotFoundException(groupId);
} catch (Signal.Error.NotAGroupMember e) {
throw new NotAGroupMemberException(groupId, group.Get("org.asamk.Signal.Group", "Name"));
}
return new SendGroupMessageResults(0, List.of());
}
@Override @Override
public Pair<GroupId, SendGroupMessageResults> createGroup( public Pair<GroupId, SendGroupMessageResults> createGroup(
final String name, final String name,
@ -824,6 +839,7 @@ public class DbusManagerImpl implements Manager {
0, 0,
false, false,
contactBlocked, contactBlocked,
0,
false, false,
false, false,
false, false,
@ -871,7 +887,8 @@ public class DbusManagerImpl implements Manager {
GroupPermission.valueOf((String) group.get("PermissionEditDetails").getValue()), GroupPermission.valueOf((String) group.get("PermissionEditDetails").getValue()),
GroupPermission.valueOf((String) group.get("PermissionSendMessage").getValue()), GroupPermission.valueOf((String) group.get("PermissionSendMessage").getValue()),
(boolean) group.get("IsMember").getValue(), (boolean) group.get("IsMember").getValue(),
(boolean) group.get("IsAdmin").getValue()); (boolean) group.get("IsAdmin").getValue(),
group.get("IsTerminated") != null && (boolean) group.get("IsTerminated").getValue());
} catch (GroupInviteLinkUrl.InvalidGroupLinkException | GroupInviteLinkUrl.UnknownGroupLinkVersionException e) { } catch (GroupInviteLinkUrl.InvalidGroupLinkException | GroupInviteLinkUrl.UnknownGroupLinkVersionException e) {
throw new AssertionError(e); throw new AssertionError(e);
} }

View File

@ -1308,6 +1308,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
new DbusProperty<>("IsBlocked", () -> getGroup().isBlocked(), this::setIsBlocked), new DbusProperty<>("IsBlocked", () -> getGroup().isBlocked(), this::setIsBlocked),
new DbusProperty<>("IsMember", () -> getGroup().isMember()), new DbusProperty<>("IsMember", () -> getGroup().isMember()),
new DbusProperty<>("IsAdmin", () -> getGroup().isAdmin()), new DbusProperty<>("IsAdmin", () -> getGroup().isAdmin()),
new DbusProperty<>("IsTerminated", () -> getGroup().isTerminated()),
new DbusProperty<>("MessageExpirationTimer", new DbusProperty<>("MessageExpirationTimer",
() -> getGroup().messageExpirationTimer(), () -> getGroup().messageExpirationTimer(),
this::setMessageExpirationTime), this::setMessageExpirationTime),
@ -1375,6 +1376,19 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
updateGroups(); updateGroups();
} }
@Override
public void terminateGroup() throws Error.Failure {
try {
m.terminateGroup(groupId);
} catch (GroupNotFoundException e) {
throw new Error.GroupNotFound(e.getMessage());
} catch (NotAGroupMemberException e) {
throw new Error.NotAGroupMember(e.getMessage());
} catch (IOException e) {
throw new Error.Failure(e.getMessage());
}
}
@Override @Override
public void addMembers(final List<String> recipients) throws Error.Failure { public void addMembers(final List<String> recipients) throws Error.Failure {
final var memberIdentifiers = getSingleRecipientIdentifiers(recipients, m.getSelfNumber()); final var memberIdentifiers = getSingleRecipientIdentifiers(recipients, m.getSelfNumber());

View File

@ -13,7 +13,8 @@ record JsonAttachment(
Integer width, Integer width,
Integer height, Integer height,
String caption, String caption,
Long uploadTimestamp Long uploadTimestamp,
boolean isVoiceNote
) { ) {
static JsonAttachment from(MessageEnvelope.Data.Attachment attachment) { static JsonAttachment from(MessageEnvelope.Data.Attachment attachment) {
@ -32,6 +33,7 @@ record JsonAttachment(
width, width,
height, height,
caption, caption,
uploadTimestamp); uploadTimestamp,
attachment.isVoiceNote());
} }
} }

View File

@ -16,6 +16,10 @@ record JsonDataMessage(
Integer expiresInSeconds, Integer expiresInSeconds,
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean isExpirationUpdate, @JsonInclude(JsonInclude.Include.NON_NULL) Boolean isExpirationUpdate,
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean viewOnce, @JsonInclude(JsonInclude.Include.NON_NULL) Boolean viewOnce,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonGroupCallUpdate groupCallUpdate,
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean isEndSession,
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean isProfileKeyUpdate,
@JsonInclude(JsonInclude.Include.NON_NULL) Boolean hasProfileKey,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonReaction reaction, @JsonInclude(JsonInclude.Include.NON_NULL) JsonReaction reaction,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonQuote quote, @JsonInclude(JsonInclude.Include.NON_NULL) JsonQuote quote,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonPayment payment, @JsonInclude(JsonInclude.Include.NON_NULL) JsonPayment payment,
@ -47,6 +51,10 @@ record JsonDataMessage(
final var expiresInSeconds = dataMessage.expiresInSeconds(); final var expiresInSeconds = dataMessage.expiresInSeconds();
final var isExpirationUpdate = dataMessage.isExpirationUpdate(); final var isExpirationUpdate = dataMessage.isExpirationUpdate();
final var viewOnce = dataMessage.isViewOnce(); final var viewOnce = dataMessage.isViewOnce();
final var groupCallUpdate = dataMessage.groupCallUpdate().map(JsonGroupCallUpdate::from).orElse(null);
final var isEndSession = dataMessage.isEndSession();
final var isProfileKeyUpdate = dataMessage.isProfileKeyUpdate();
final var hasProfileKey = dataMessage.hasProfileKey();
final var reaction = dataMessage.reaction().map(JsonReaction::from).orElse(null); final var reaction = dataMessage.reaction().map(JsonReaction::from).orElse(null);
final var quote = dataMessage.quote().isPresent() ? JsonQuote.from(dataMessage.quote().get()) : null; final var quote = dataMessage.quote().isPresent() ? JsonQuote.from(dataMessage.quote().get()) : null;
final var payment = dataMessage.payment().isPresent() ? JsonPayment.from(dataMessage.payment().get()) : null; final var payment = dataMessage.payment().isPresent() ? JsonPayment.from(dataMessage.payment().get()) : null;
@ -86,6 +94,10 @@ record JsonDataMessage(
expiresInSeconds, expiresInSeconds,
isExpirationUpdate, isExpirationUpdate,
viewOnce, viewOnce,
groupCallUpdate,
isEndSession,
isProfileKeyUpdate,
hasProfileKey,
reaction, reaction,
quote, quote,
payment, payment,

View File

@ -0,0 +1,13 @@
package org.asamk.signal.json;
import org.asamk.signal.manager.api.MessageEnvelope;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "GroupCallUpdate")
record JsonGroupCallUpdate(String eraId) {
static JsonGroupCallUpdate from(MessageEnvelope.Data.GroupCallUpdate groupCallUpdate) {
return new JsonGroupCallUpdate(groupCallUpdate.eraId());
}
}

View File

@ -3768,6 +3768,32 @@
"java.lang.String" "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>", "name": "<init>",
"parameterTypes": [ "parameterTypes": [
@ -3818,6 +3844,10 @@
"name": "aciAccountData", "name": "aciAccountData",
"parameterTypes": [] "parameterTypes": []
}, },
{
"name": "authCredentialSalt",
"parameterTypes": []
},
{ {
"name": "deviceId", "name": "deviceId",
"parameterTypes": [] "parameterTypes": []

View File

@ -0,0 +1,47 @@
package org.asamk.signal.json;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.util.Util;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JsonAttachmentTest {
@Test
void serializesVoiceNoteFlag() {
assertVoiceNoteFlag(true);
}
@Test
void serializesNonVoiceNoteFlag() {
assertVoiceNoteFlag(false);
}
private static void assertVoiceNoteFlag(final boolean isVoiceNote) {
final var attachment = new MessageEnvelope.Data.Attachment(Optional.empty(),
Optional.empty(),
Optional.empty(),
"audio/aac",
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
isVoiceNote,
false,
false);
final var jsonAttachment = JsonAttachment.from(attachment);
final var json = Util.createJsonObjectMapper().valueToTree(jsonAttachment);
assertEquals(isVoiceNote, jsonAttachment.isVoiceNote());
assertTrue(json.has("isVoiceNote"));
assertEquals(isVoiceNote, json.get("isVoiceNote").booleanValue());
}
}

View File

@ -0,0 +1,77 @@
package org.asamk.signal.json;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.util.Util;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
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 JsonDataMessageTest {
@Test
void serializesProtocolMetadata() {
final var dataMessage = dataMessage(Optional.of(new MessageEnvelope.Data.GroupCallUpdate("era-id")),
true,
true,
true);
final var json = Util.createJsonObjectMapper().valueToTree(JsonDataMessage.from(dataMessage, null));
assertEquals("era-id", json.get("groupCallUpdate").get("eraId").textValue());
assertTrue(json.get("isEndSession").booleanValue());
assertTrue(json.get("isProfileKeyUpdate").booleanValue());
assertTrue(json.get("hasProfileKey").booleanValue());
}
@Test
void omitsAbsentGroupCallUpdateAndSerializesFalseFlags() {
final var dataMessage = dataMessage(Optional.empty(), false, false, false);
final var json = Util.createJsonObjectMapper().valueToTree(JsonDataMessage.from(dataMessage, null));
assertFalse(json.has("groupCallUpdate"));
assertFalse(json.get("isEndSession").booleanValue());
assertFalse(json.get("isProfileKeyUpdate").booleanValue());
assertFalse(json.get("hasProfileKey").booleanValue());
}
private static MessageEnvelope.Data dataMessage(
final Optional<MessageEnvelope.Data.GroupCallUpdate> groupCallUpdate,
final boolean isEndSession,
final boolean isProfileKeyUpdate,
final boolean hasProfileKey
) {
return new MessageEnvelope.Data(1L,
Optional.empty(),
Optional.empty(),
groupCallUpdate,
Optional.empty(),
0,
false,
false,
isEndSession,
isProfileKeyUpdate,
hasProfileKey,
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of(),
Optional.empty(),
Optional.empty(),
List.of(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
List.of(),
List.of(),
List.of(),
Optional.empty(),
Optional.empty(),
Optional.empty());
}
}