mirror of
https://github.com/AsamK/signal-cli
synced 2025-08-29 10:30:38 +00:00
Move group store to database
This commit is contained in:
parent
46adc1af98
commit
65c9a2e185
9 changed files with 659 additions and 399 deletions
|
@ -1031,45 +1031,40 @@
|
|||
"allDeclaredConstructors":true
|
||||
},
|
||||
{
|
||||
"name":"org.asamk.signal.manager.storage.groups.GroupStore$GroupsDeserializer",
|
||||
"name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$GroupsDeserializer",
|
||||
"methods":[{"name":"<init>","parameterTypes":[] }]
|
||||
},
|
||||
{
|
||||
"name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage",
|
||||
"allDeclaredFields":true,
|
||||
"allDeclaredMethods":true,
|
||||
"allDeclaredConstructors":true
|
||||
},
|
||||
{
|
||||
"name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV1",
|
||||
"allDeclaredFields":true,
|
||||
"allDeclaredMethods":true,
|
||||
"allDeclaredConstructors":true
|
||||
},
|
||||
{
|
||||
"name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV1$JsonRecipientAddress",
|
||||
"name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage",
|
||||
"allDeclaredFields":true,
|
||||
"queryAllDeclaredMethods":true,
|
||||
"queryAllDeclaredConstructors":true,
|
||||
"methods":[
|
||||
{"name":"<init>","parameterTypes":["java.lang.String","java.lang.String"] },
|
||||
{"name":"number","parameterTypes":[] },
|
||||
{"name":"uuid","parameterTypes":[] }
|
||||
]
|
||||
"methods":[{"name":"<init>","parameterTypes":["java.util.List"] }]
|
||||
},
|
||||
{
|
||||
"name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV1$MembersDeserializer",
|
||||
"methods":[{"name":"<init>","parameterTypes":[] }]
|
||||
},
|
||||
{
|
||||
"name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV1$MembersSerializer",
|
||||
"methods":[{"name":"<init>","parameterTypes":[] }]
|
||||
},
|
||||
{
|
||||
"name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV2",
|
||||
"name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage$GroupV1",
|
||||
"allDeclaredFields":true,
|
||||
"allDeclaredMethods":true,
|
||||
"allDeclaredConstructors":true
|
||||
"queryAllDeclaredMethods":true,
|
||||
"queryAllDeclaredConstructors":true,
|
||||
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","java.lang.String","java.lang.String","java.lang.String","int","boolean","boolean","java.util.List"] }]
|
||||
},
|
||||
{
|
||||
"name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage$GroupV1$JsonRecipientAddress",
|
||||
"allDeclaredFields":true,
|
||||
"queryAllDeclaredMethods":true,
|
||||
"queryAllDeclaredConstructors":true,
|
||||
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","java.lang.String"] }]
|
||||
},
|
||||
{
|
||||
"name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage$GroupV1$MembersDeserializer",
|
||||
"methods":[{"name":"<init>","parameterTypes":[] }]
|
||||
},
|
||||
{
|
||||
"name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage$GroupV2",
|
||||
"allDeclaredFields":true,
|
||||
"queryAllDeclaredMethods":true,
|
||||
"queryAllDeclaredConstructors":true,
|
||||
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","java.lang.String","java.lang.String","boolean","boolean"] }]
|
||||
},
|
||||
{
|
||||
"name":"org.asamk.signal.manager.storage.identities.IdentityKeyStore$IdentityStorage",
|
||||
|
@ -1223,13 +1218,6 @@
|
|||
"queryAllDeclaredConstructors":true,
|
||||
"methods":[{"name":"<init>","parameterTypes":["java.lang.String","java.lang.String","boolean"] }]
|
||||
},
|
||||
{
|
||||
"name":"org.asamk.signal.manager.storage.stickers.StickerStore",
|
||||
"allDeclaredFields":true,
|
||||
"allDeclaredMethods":true,
|
||||
"allDeclaredConstructors":true,
|
||||
"fields":[{"name":"stickers", "allowWrite":true}]
|
||||
},
|
||||
{
|
||||
"name":"org.asamk.signal.util.SecurityProvider$DefaultRandom",
|
||||
"methods":[{"name":"<init>","parameterTypes":[] }]
|
||||
|
|
|
@ -111,15 +111,17 @@ public class GroupHelper {
|
|||
final GroupInfoV2 groupInfoV2;
|
||||
if (groupInfo instanceof GroupInfoV1) {
|
||||
// Received a v2 group message for a v1 group, we need to locally migrate the group
|
||||
account.getGroupStore().deleteGroupV1(((GroupInfoV1) groupInfo).getGroupId());
|
||||
groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey);
|
||||
account.getGroupStore().deleteGroup(((GroupInfoV1) groupInfo).getGroupId());
|
||||
groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey, account.getRecipientResolver());
|
||||
groupInfoV2.setBlocked(groupInfo.isBlocked());
|
||||
account.getGroupStore().updateGroup(groupInfoV2);
|
||||
logger.info("Locally migrated group {} to group v2, id: {}",
|
||||
groupInfo.getGroupId().toBase64(),
|
||||
groupInfoV2.getGroupId().toBase64());
|
||||
} else if (groupInfo instanceof GroupInfoV2) {
|
||||
groupInfoV2 = (GroupInfoV2) groupInfo;
|
||||
} else {
|
||||
groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey);
|
||||
groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey, account.getRecipientResolver());
|
||||
}
|
||||
|
||||
if (groupInfoV2.getGroup() == null || groupInfoV2.getGroup().getRevision() < revision) {
|
||||
|
@ -153,7 +155,7 @@ public class GroupHelper {
|
|||
downloadGroupAvatar(groupId, groupSecretParams, avatar);
|
||||
}
|
||||
}
|
||||
groupInfoV2.setGroup(group, account.getRecipientResolver());
|
||||
groupInfoV2.setGroup(group);
|
||||
account.getGroupStore().updateGroup(groupInfoV2);
|
||||
}
|
||||
|
||||
|
@ -183,7 +185,7 @@ public class GroupHelper {
|
|||
final var gv2 = gv2Pair.first();
|
||||
final var decryptedGroup = gv2Pair.second();
|
||||
|
||||
gv2.setGroup(decryptedGroup, account.getRecipientResolver());
|
||||
gv2.setGroup(decryptedGroup);
|
||||
if (avatarFile != null) {
|
||||
context.getAvatarStore()
|
||||
.storeGroupAvatar(gv2.getGroupId(),
|
||||
|
@ -398,7 +400,7 @@ public class GroupHelper {
|
|||
downloadGroupAvatar(groupInfoV2.getGroupId(), groupSecretParams, avatar);
|
||||
}
|
||||
}
|
||||
groupInfoV2.setGroup(decryptedGroup, account.getRecipientResolver());
|
||||
groupInfoV2.setGroup(decryptedGroup);
|
||||
account.getGroupStore().updateGroup(group);
|
||||
}
|
||||
}
|
||||
|
@ -729,7 +731,7 @@ public class GroupHelper {
|
|||
throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle());
|
||||
}
|
||||
final var groupGroupChangePair = context.getGroupV2Helper().leaveGroup(groupInfoV2, newAdmins);
|
||||
groupInfoV2.setGroup(groupGroupChangePair.first(), account.getRecipientResolver());
|
||||
groupInfoV2.setGroup(groupGroupChangePair.first());
|
||||
account.getGroupStore().updateGroup(groupInfoV2);
|
||||
|
||||
var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray());
|
||||
|
@ -773,7 +775,7 @@ public class GroupHelper {
|
|||
) throws IOException {
|
||||
final var selfRecipientId = account.getSelfRecipientId();
|
||||
final var members = group.getMembersIncludingPendingWithout(selfRecipientId);
|
||||
group.setGroup(newDecryptedGroup, account.getRecipientResolver());
|
||||
group.setGroup(newDecryptedGroup);
|
||||
members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId));
|
||||
account.getGroupStore().updateGroup(group);
|
||||
|
||||
|
|
|
@ -161,7 +161,7 @@ class GroupV2Helper {
|
|||
|
||||
final var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
|
||||
final var masterKey = groupSecretParams.getMasterKey();
|
||||
var g = new GroupInfoV2(groupId, masterKey);
|
||||
var g = new GroupInfoV2(groupId, masterKey, context.getAccount().getRecipientResolver());
|
||||
|
||||
return new Pair<>(g, decryptedGroup);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package org.asamk.signal.manager.storage;
|
|||
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
|
||||
import org.asamk.signal.manager.storage.groups.GroupStore;
|
||||
import org.asamk.signal.manager.storage.prekeys.PreKeyStore;
|
||||
import org.asamk.signal.manager.storage.prekeys.SignedPreKeyStore;
|
||||
import org.asamk.signal.manager.storage.recipients.RecipientStore;
|
||||
|
@ -17,7 +18,7 @@ import java.sql.SQLException;
|
|||
public class AccountDatabase extends Database {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(AccountDatabase.class);
|
||||
private static final long DATABASE_VERSION = 4;
|
||||
private static final long DATABASE_VERSION = 5;
|
||||
|
||||
private AccountDatabase(final HikariDataSource dataSource) {
|
||||
super(logger, DATABASE_VERSION, dataSource);
|
||||
|
@ -34,6 +35,7 @@ public class AccountDatabase extends Database {
|
|||
StickerStore.createSql(connection);
|
||||
PreKeyStore.createSql(connection);
|
||||
SignedPreKeyStore.createSql(connection);
|
||||
GroupStore.createSql(connection);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -109,5 +111,37 @@ public class AccountDatabase extends Database {
|
|||
""");
|
||||
}
|
||||
}
|
||||
if (oldVersion < 5) {
|
||||
logger.debug("Updating database: Creating group tables");
|
||||
try (final var statement = connection.createStatement()) {
|
||||
statement.executeUpdate("""
|
||||
CREATE TABLE group_v2 (
|
||||
_id INTEGER PRIMARY KEY,
|
||||
group_id BLOB UNIQUE NOT NULL,
|
||||
master_key BLOB NOT NULL,
|
||||
group_data BLOB,
|
||||
distribution_id BLOB UNIQUE NOT NULL,
|
||||
blocked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
permission_denied BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
CREATE TABLE group_v1 (
|
||||
_id INTEGER PRIMARY KEY,
|
||||
group_id BLOB UNIQUE NOT NULL,
|
||||
group_id_v2 BLOB UNIQUE,
|
||||
name TEXT,
|
||||
color TEXT,
|
||||
expiration_time INTEGER NOT NULL DEFAULT 0,
|
||||
blocked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
archived BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
CREATE TABLE group_v1_member (
|
||||
_id INTEGER PRIMARY KEY,
|
||||
group_id INTEGER NOT NULL REFERENCES group_v1 (_id) ON DELETE CASCADE,
|
||||
recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
|
||||
UNIQUE(group_id, recipient_id)
|
||||
);
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,8 +12,8 @@ import org.asamk.signal.manager.storage.configuration.ConfigurationStore;
|
|||
import org.asamk.signal.manager.storage.contacts.ContactsStore;
|
||||
import org.asamk.signal.manager.storage.contacts.LegacyJsonContactsStore;
|
||||
import org.asamk.signal.manager.storage.groups.GroupInfoV1;
|
||||
import org.asamk.signal.manager.storage.groups.GroupInfoV2;
|
||||
import org.asamk.signal.manager.storage.groups.GroupStore;
|
||||
import org.asamk.signal.manager.storage.groups.LegacyGroupStore;
|
||||
import org.asamk.signal.manager.storage.identities.IdentityKeyStore;
|
||||
import org.asamk.signal.manager.storage.identities.SignalIdentityKeyStore;
|
||||
import org.asamk.signal.manager.storage.identities.TrustNewIdentity;
|
||||
|
@ -61,7 +61,6 @@ import org.whispersystems.signalservice.api.SignalServiceDataStore;
|
|||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||
import org.whispersystems.signalservice.api.push.ACI;
|
||||
import org.whispersystems.signalservice.api.push.DistributionId;
|
||||
import org.whispersystems.signalservice.api.push.PNI;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
import org.whispersystems.signalservice.api.push.ServiceIdType;
|
||||
|
@ -147,7 +146,6 @@ public class SignalAccount implements Closeable {
|
|||
private SignalIdentityKeyStore aciIdentityKeyStore;
|
||||
private SenderKeyStore senderKeyStore;
|
||||
private GroupStore groupStore;
|
||||
private GroupStore.Storage groupStoreStorage;
|
||||
private RecipientStore recipientStore;
|
||||
private StickerStore stickerStore;
|
||||
private ConfigurationStore configurationStore;
|
||||
|
@ -216,9 +214,6 @@ public class SignalAccount implements Closeable {
|
|||
signalAccount.localRegistrationId = registrationId;
|
||||
signalAccount.localPniRegistrationId = pniRegistrationId;
|
||||
signalAccount.trustNewIdentity = trustNewIdentity;
|
||||
signalAccount.groupStore = new GroupStore(getGroupCachePath(dataPath, accountPath),
|
||||
signalAccount.getRecipientResolver(),
|
||||
signalAccount::saveGroupStore);
|
||||
signalAccount.configurationStore = new ConfigurationStore(signalAccount::saveConfigurationStore);
|
||||
|
||||
signalAccount.registered = false;
|
||||
|
@ -340,9 +335,6 @@ public class SignalAccount implements Closeable {
|
|||
pniIdentityKey,
|
||||
profileKey);
|
||||
|
||||
signalAccount.groupStore = new GroupStore(getGroupCachePath(dataPath, accountPath),
|
||||
signalAccount.getRecipientResolver(),
|
||||
signalAccount::saveGroupStore);
|
||||
signalAccount.configurationStore = new ConfigurationStore(signalAccount::saveConfigurationStore);
|
||||
|
||||
signalAccount.getRecipientTrustedResolver()
|
||||
|
@ -394,15 +386,6 @@ public class SignalAccount implements Closeable {
|
|||
// Old config file, creating new profile key
|
||||
setProfileKey(KeyUtils.createProfileKey());
|
||||
}
|
||||
if (previousStorageVersion < 3) {
|
||||
for (final var group : groupStore.getGroups()) {
|
||||
if (group instanceof GroupInfoV2 && group.getDistributionId() == null) {
|
||||
((GroupInfoV2) group).setDistributionId(DistributionId.create());
|
||||
groupStore.updateGroup(group);
|
||||
}
|
||||
}
|
||||
save();
|
||||
}
|
||||
if (isPrimaryDevice() && getPniIdentityKeyPair() == null) {
|
||||
setPniIdentityKeyPair(KeyUtils.generateIdentityKeyPair());
|
||||
}
|
||||
|
@ -668,15 +651,13 @@ public class SignalAccount implements Closeable {
|
|||
migratedLegacyConfig = loadLegacyStores(rootNode, legacySignalProtocolStore) || migratedLegacyConfig;
|
||||
|
||||
if (rootNode.hasNonNull("groupStore")) {
|
||||
groupStoreStorage = jsonProcessor.convertValue(rootNode.get("groupStore"), GroupStore.Storage.class);
|
||||
groupStore = GroupStore.fromStorage(groupStoreStorage,
|
||||
final var groupStoreStorage = jsonProcessor.convertValue(rootNode.get("groupStore"),
|
||||
LegacyGroupStore.Storage.class);
|
||||
LegacyGroupStore.migrate(groupStoreStorage,
|
||||
getGroupCachePath(dataPath, accountPath),
|
||||
getRecipientResolver(),
|
||||
this::saveGroupStore);
|
||||
} else {
|
||||
groupStore = new GroupStore(getGroupCachePath(dataPath, accountPath),
|
||||
getRecipientResolver(),
|
||||
this::saveGroupStore);
|
||||
getGroupStore());
|
||||
migratedLegacyConfig = true;
|
||||
}
|
||||
|
||||
if (rootNode.hasNonNull("stickerStore")) {
|
||||
|
@ -858,10 +839,10 @@ public class SignalAccount implements Closeable {
|
|||
.build());
|
||||
}
|
||||
} else {
|
||||
var groupInfo = groupStore.getGroup(GroupId.fromBase64(thread.id));
|
||||
var groupInfo = getGroupStore().getGroup(GroupId.fromBase64(thread.id));
|
||||
if (groupInfo instanceof GroupInfoV1) {
|
||||
((GroupInfoV1) groupInfo).messageExpirationTime = thread.messageExpirationTime;
|
||||
groupStore.updateGroup(groupInfo);
|
||||
getGroupStore().updateGroup(groupInfo);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
|
@ -874,11 +855,6 @@ public class SignalAccount implements Closeable {
|
|||
return false;
|
||||
}
|
||||
|
||||
private void saveGroupStore(GroupStore.Storage storage) {
|
||||
this.groupStoreStorage = storage;
|
||||
save();
|
||||
}
|
||||
|
||||
private void saveConfigurationStore(ConfigurationStore.Storage storage) {
|
||||
this.configurationStoreStorage = storage;
|
||||
save();
|
||||
|
@ -925,7 +901,6 @@ public class SignalAccount implements Closeable {
|
|||
.put("profileKey",
|
||||
profileKey == null ? null : Base64.getEncoder().encodeToString(profileKey.serialize()))
|
||||
.put("registered", registered)
|
||||
.putPOJO("groupStore", groupStoreStorage)
|
||||
.putPOJO("configurationStore", configurationStoreStorage);
|
||||
try {
|
||||
try (var output = new ByteArrayOutputStream()) {
|
||||
|
@ -1111,7 +1086,10 @@ public class SignalAccount implements Closeable {
|
|||
}
|
||||
|
||||
public GroupStore getGroupStore() {
|
||||
return groupStore;
|
||||
return getOrCreate(() -> groupStore,
|
||||
() -> groupStore = new GroupStore(getAccountDatabase(),
|
||||
getRecipientResolver(),
|
||||
getRecipientIdCreator()));
|
||||
}
|
||||
|
||||
public ContactsStore getContactStore() {
|
||||
|
|
|
@ -43,7 +43,7 @@ public final class GroupInfoV1 extends GroupInfo {
|
|||
this.groupId = groupId;
|
||||
this.expectedV2Id = expectedV2Id;
|
||||
this.name = name;
|
||||
this.members = members;
|
||||
this.members = new HashSet<>(members);
|
||||
this.color = color;
|
||||
this.messageExpirationTime = messageExpirationTime;
|
||||
this.blocked = blocked;
|
||||
|
@ -78,7 +78,7 @@ public final class GroupInfoV1 extends GroupInfo {
|
|||
}
|
||||
|
||||
public Set<RecipientId> getMembers() {
|
||||
return members;
|
||||
return new HashSet<>(members);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -22,29 +22,36 @@ public final class GroupInfoV2 extends GroupInfo {
|
|||
private final GroupMasterKey masterKey;
|
||||
private DistributionId distributionId;
|
||||
private boolean blocked;
|
||||
private DecryptedGroup group; // stored as a file with base64 groupId as name
|
||||
private DecryptedGroup group;
|
||||
private boolean permissionDenied;
|
||||
|
||||
private RecipientResolver recipientResolver;
|
||||
private final RecipientResolver recipientResolver;
|
||||
|
||||
public GroupInfoV2(final GroupIdV2 groupId, final GroupMasterKey masterKey) {
|
||||
public GroupInfoV2(
|
||||
final GroupIdV2 groupId, final GroupMasterKey masterKey, final RecipientResolver recipientResolver
|
||||
) {
|
||||
this.groupId = groupId;
|
||||
this.masterKey = masterKey;
|
||||
this.distributionId = DistributionId.create();
|
||||
this.recipientResolver = recipientResolver;
|
||||
}
|
||||
|
||||
public GroupInfoV2(
|
||||
final GroupIdV2 groupId,
|
||||
final GroupMasterKey masterKey,
|
||||
final DecryptedGroup group,
|
||||
final DistributionId distributionId,
|
||||
final boolean blocked,
|
||||
final boolean permissionDenied
|
||||
final boolean permissionDenied,
|
||||
final RecipientResolver recipientResolver
|
||||
) {
|
||||
this.groupId = groupId;
|
||||
this.masterKey = masterKey;
|
||||
this.group = group;
|
||||
this.distributionId = distributionId;
|
||||
this.blocked = blocked;
|
||||
this.permissionDenied = permissionDenied;
|
||||
this.recipientResolver = recipientResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -60,16 +67,11 @@ public final class GroupInfoV2 extends GroupInfo {
|
|||
return distributionId;
|
||||
}
|
||||
|
||||
public void setDistributionId(final DistributionId distributionId) {
|
||||
this.distributionId = distributionId;
|
||||
}
|
||||
|
||||
public void setGroup(final DecryptedGroup group, final RecipientResolver recipientResolver) {
|
||||
public void setGroup(final DecryptedGroup group) {
|
||||
if (group != null) {
|
||||
this.permissionDenied = false;
|
||||
}
|
||||
this.group = group;
|
||||
this.recipientResolver = recipientResolver;
|
||||
}
|
||||
|
||||
public DecryptedGroup getGroup() {
|
||||
|
|
|
@ -1,24 +1,16 @@
|
|||
package org.asamk.signal.manager.storage.groups;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupId;
|
||||
import org.asamk.signal.manager.groups.GroupIdV1;
|
||||
import org.asamk.signal.manager.groups.GroupIdV2;
|
||||
import org.asamk.signal.manager.groups.GroupUtils;
|
||||
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
|
||||
import org.asamk.signal.manager.storage.Database;
|
||||
import org.asamk.signal.manager.storage.Utils;
|
||||
import org.asamk.signal.manager.storage.recipients.RecipientId;
|
||||
import org.asamk.signal.manager.storage.recipients.RecipientIdCreator;
|
||||
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
|
||||
import org.asamk.signal.manager.util.IOUtils;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
|
@ -26,359 +18,421 @@ import org.slf4j.Logger;
|
|||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.push.DistributionId;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.util.Hex;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class GroupStore {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(GroupStore.class);
|
||||
private static final String TABLE_GROUP_V2 = "group_v2";
|
||||
private static final String TABLE_GROUP_V1 = "group_v1";
|
||||
private static final String TABLE_GROUP_V1_MEMBER = "group_v1_member";
|
||||
|
||||
private final File groupCachePath;
|
||||
private final Map<GroupId, GroupInfo> groups;
|
||||
private final Database database;
|
||||
private final RecipientResolver recipientResolver;
|
||||
private final Saver saver;
|
||||
private final RecipientIdCreator recipientIdCreator;
|
||||
|
||||
private GroupStore(
|
||||
final File groupCachePath,
|
||||
final Map<GroupId, GroupInfo> groups,
|
||||
final RecipientResolver recipientResolver,
|
||||
final Saver saver
|
||||
) {
|
||||
this.groupCachePath = groupCachePath;
|
||||
this.groups = groups;
|
||||
this.recipientResolver = recipientResolver;
|
||||
this.saver = saver;
|
||||
public static void createSql(Connection connection) throws SQLException {
|
||||
// When modifying the CREATE statement here, also add a migration in AccountDatabase.java
|
||||
try (final var statement = connection.createStatement()) {
|
||||
statement.executeUpdate("""
|
||||
CREATE TABLE group_v2 (
|
||||
_id INTEGER PRIMARY KEY,
|
||||
group_id BLOB UNIQUE NOT NULL,
|
||||
master_key BLOB NOT NULL,
|
||||
group_data BLOB,
|
||||
distribution_id BLOB UNIQUE NOT NULL,
|
||||
blocked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
permission_denied BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
CREATE TABLE group_v1 (
|
||||
_id INTEGER PRIMARY KEY,
|
||||
group_id BLOB UNIQUE NOT NULL,
|
||||
group_id_v2 BLOB UNIQUE,
|
||||
name TEXT,
|
||||
color TEXT,
|
||||
expiration_time INTEGER NOT NULL DEFAULT 0,
|
||||
blocked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
archived BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
CREATE TABLE group_v1_member (
|
||||
_id INTEGER PRIMARY KEY,
|
||||
group_id INTEGER NOT NULL REFERENCES group_v1 (_id) ON DELETE CASCADE,
|
||||
recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
|
||||
UNIQUE(group_id, recipient_id)
|
||||
);
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
public GroupStore(
|
||||
final File groupCachePath, final RecipientResolver recipientResolver, final Saver saver
|
||||
) {
|
||||
this.groups = new HashMap<>();
|
||||
this.groupCachePath = groupCachePath;
|
||||
this.recipientResolver = recipientResolver;
|
||||
this.saver = saver;
|
||||
}
|
||||
|
||||
public static GroupStore fromStorage(
|
||||
final Storage storage,
|
||||
final File groupCachePath,
|
||||
final Database database,
|
||||
final RecipientResolver recipientResolver,
|
||||
final Saver saver
|
||||
final RecipientIdCreator recipientIdCreator
|
||||
) {
|
||||
final var groups = storage.groups.stream().map(g -> {
|
||||
if (g instanceof Storage.GroupV1 g1) {
|
||||
final var members = g1.members.stream().map(m -> {
|
||||
if (m.recipientId == null) {
|
||||
return recipientResolver.resolveRecipient(new RecipientAddress(UuidUtil.parseOrNull(m.uuid),
|
||||
m.number));
|
||||
}
|
||||
|
||||
return recipientResolver.resolveRecipient(m.recipientId);
|
||||
}).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
|
||||
return new GroupInfoV1(GroupIdV1.fromBase64(g1.groupId),
|
||||
g1.expectedV2Id == null ? null : GroupIdV2.fromBase64(g1.expectedV2Id),
|
||||
g1.name,
|
||||
members,
|
||||
g1.color,
|
||||
g1.messageExpirationTime,
|
||||
g1.blocked,
|
||||
g1.archived);
|
||||
}
|
||||
|
||||
final var g2 = (Storage.GroupV2) g;
|
||||
var groupId = GroupIdV2.fromBase64(g2.groupId);
|
||||
GroupMasterKey masterKey;
|
||||
try {
|
||||
masterKey = new GroupMasterKey(Base64.getDecoder().decode(g2.masterKey));
|
||||
} catch (InvalidInputException | IllegalArgumentException e) {
|
||||
throw new AssertionError("Invalid master key for group " + groupId.toBase64());
|
||||
}
|
||||
|
||||
return new GroupInfoV2(groupId,
|
||||
masterKey,
|
||||
g2.distributionId == null ? null : DistributionId.from(g2.distributionId),
|
||||
g2.blocked,
|
||||
g2.permissionDenied);
|
||||
}).collect(Collectors.toMap(GroupInfo::getGroupId, g -> g));
|
||||
|
||||
return new GroupStore(groupCachePath, groups, recipientResolver, saver);
|
||||
this.database = database;
|
||||
this.recipientResolver = recipientResolver;
|
||||
this.recipientIdCreator = recipientIdCreator;
|
||||
}
|
||||
|
||||
public void updateGroup(GroupInfo group) {
|
||||
final Storage storage;
|
||||
synchronized (groups) {
|
||||
groups.put(group.getGroupId(), group);
|
||||
if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() != null) {
|
||||
try {
|
||||
IOUtils.createPrivateDirectories(groupCachePath);
|
||||
try (var stream = new FileOutputStream(getGroupV2File(group.getGroupId()))) {
|
||||
((GroupInfoV2) group).getGroup().writeTo(stream);
|
||||
try (final var connection = database.getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
final Long internalId;
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT g._id
|
||||
FROM %s g
|
||||
WHERE g.group_id = ?
|
||||
"""
|
||||
).formatted(group instanceof GroupInfoV1 ? TABLE_GROUP_V1 : TABLE_GROUP_V2);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, group.getGroupId().serialize());
|
||||
internalId = Utils.executeQueryForOptional(statement, res -> res.getLong("_id")).orElse(null);
|
||||
}
|
||||
final var groupFileLegacy = getGroupV2FileLegacy(group.getGroupId());
|
||||
if (groupFileLegacy.exists()) {
|
||||
try {
|
||||
Files.delete(groupFileLegacy.toPath());
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to delete legacy group file {}: {}", groupFileLegacy, e.getMessage());
|
||||
insertOrReplaceGroup(connection, internalId, group);
|
||||
connection.commit();
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed update recipient store", e);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to cache group, ignoring: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
storage = toStorageLocked();
|
||||
}
|
||||
saver.save(storage);
|
||||
}
|
||||
|
||||
public void deleteGroupV1(GroupIdV1 groupIdV1) {
|
||||
deleteGroup(groupIdV1);
|
||||
}
|
||||
|
||||
public void deleteGroup(GroupId groupId) {
|
||||
final Storage storage;
|
||||
synchronized (groups) {
|
||||
groups.remove(groupId);
|
||||
storage = toStorageLocked();
|
||||
if (groupId instanceof GroupIdV1 groupIdV1) {
|
||||
deleteGroup(groupIdV1);
|
||||
} else if (groupId instanceof GroupIdV2 groupIdV2) {
|
||||
deleteGroup(groupIdV2);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteGroup(GroupIdV1 groupIdV1) {
|
||||
final var sql = (
|
||||
"""
|
||||
DELETE FROM %s
|
||||
WHERE group_id = ?
|
||||
"""
|
||||
).formatted(TABLE_GROUP_V1);
|
||||
try (final var connection = database.getConnection()) {
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, groupIdV1.serialize());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed update group store", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteGroup(GroupIdV2 groupIdV2) {
|
||||
final var sql = (
|
||||
"""
|
||||
DELETE FROM %s
|
||||
WHERE group_id = ?
|
||||
"""
|
||||
).formatted(TABLE_GROUP_V2);
|
||||
try (final var connection = database.getConnection()) {
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, groupIdV2.serialize());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed update group store", e);
|
||||
}
|
||||
saver.save(storage);
|
||||
}
|
||||
|
||||
public GroupInfo getGroup(GroupId groupId) {
|
||||
synchronized (groups) {
|
||||
return getGroupLocked(groupId);
|
||||
try (final var connection = database.getConnection()) {
|
||||
if (groupId instanceof GroupIdV1 groupIdV1) {
|
||||
final var group = getGroup(connection, groupIdV1);
|
||||
if (group != null) {
|
||||
return group;
|
||||
}
|
||||
return getGroupV2ByV1Id(connection, groupIdV1);
|
||||
} else if (groupId instanceof GroupIdV2 groupIdV2) {
|
||||
final var group = getGroup(connection, groupIdV2);
|
||||
if (group != null) {
|
||||
return group;
|
||||
}
|
||||
return getGroupV1ByV2Id(connection, groupIdV2);
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed read from group store", e);
|
||||
}
|
||||
throw new AssertionError("Invalid group id type");
|
||||
}
|
||||
|
||||
public GroupInfoV1 getOrCreateGroupV1(GroupIdV1 groupId) {
|
||||
synchronized (groups) {
|
||||
var group = getGroupLocked(groupId);
|
||||
if (group instanceof GroupInfoV1) {
|
||||
return (GroupInfoV1) group;
|
||||
try (final var connection = database.getConnection()) {
|
||||
var group = getGroup(connection, groupId);
|
||||
|
||||
if (group != null) {
|
||||
return group;
|
||||
}
|
||||
|
||||
if (group == null) {
|
||||
if (getGroupV2ByV1Id(connection, groupId) == null) {
|
||||
return new GroupInfoV1(groupId);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed read from group store", e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<GroupInfo> getGroups() {
|
||||
synchronized (groups) {
|
||||
final var groups = this.groups.values();
|
||||
for (var group : groups) {
|
||||
loadDecryptedGroupLocked(group);
|
||||
}
|
||||
return new ArrayList<>(groups);
|
||||
}
|
||||
return Stream.concat(getGroupsV2().stream(), getGroupsV1().stream()).toList();
|
||||
}
|
||||
|
||||
public void mergeRecipients(final RecipientId recipientId, final RecipientId toBeMergedRecipientId) {
|
||||
Storage storage = null;
|
||||
synchronized (groups) {
|
||||
var modified = false;
|
||||
for (var group : this.groups.values()) {
|
||||
final var sql = (
|
||||
"""
|
||||
UPDATE OR REPLACE %s
|
||||
SET recipient_id = ?
|
||||
WHERE recipient_id = ?
|
||||
"""
|
||||
).formatted(TABLE_GROUP_V1_MEMBER);
|
||||
try (final var connection = database.getConnection()) {
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, recipientId.id());
|
||||
statement.setLong(2, toBeMergedRecipientId.id());
|
||||
final var updatedRows = statement.executeUpdate();
|
||||
if (updatedRows > 0) {
|
||||
logger.info("Updated {} group members when merging recipients", updatedRows);
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed update group store", e);
|
||||
}
|
||||
}
|
||||
|
||||
void addLegacyGroups(final Collection<GroupInfo> groups) {
|
||||
logger.debug("Migrating legacy groups to database");
|
||||
long start = System.nanoTime();
|
||||
try (final var connection = database.getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
for (final var group : groups) {
|
||||
insertOrReplaceGroup(connection, null, group);
|
||||
}
|
||||
connection.commit();
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed update group store", e);
|
||||
}
|
||||
logger.debug("Complete groups migration took {}ms", (System.nanoTime() - start) / 1000000);
|
||||
}
|
||||
|
||||
private void insertOrReplaceGroup(
|
||||
final Connection connection, Long internalId, final GroupInfo group
|
||||
) throws SQLException {
|
||||
if (group instanceof GroupInfoV1 groupV1) {
|
||||
if (groupV1.isMember(toBeMergedRecipientId)) {
|
||||
groupV1.removeMember(toBeMergedRecipientId);
|
||||
groupV1.addMembers(List.of(recipientId));
|
||||
modified = true;
|
||||
if (internalId != null) {
|
||||
final var sqlDeleteMembers = "DELETE FROM %s where group_id = ?".formatted(TABLE_GROUP_V1_MEMBER);
|
||||
try (final var statement = connection.prepareStatement(sqlDeleteMembers)) {
|
||||
statement.setLong(1, internalId);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
final var sql = """
|
||||
INSERT OR REPLACE INTO %s (_id, group_id, group_id_v2, name, color, expiration_time, blocked, archived)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".formatted(TABLE_GROUP_V1);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
if (internalId == null) {
|
||||
statement.setNull(1, Types.NUMERIC);
|
||||
} else {
|
||||
statement.setLong(1, internalId);
|
||||
}
|
||||
statement.setBytes(2, groupV1.getGroupId().serialize());
|
||||
statement.setBytes(3, groupV1.getExpectedV2Id().serialize());
|
||||
statement.setString(4, groupV1.getTitle());
|
||||
statement.setString(5, groupV1.color);
|
||||
statement.setLong(6, groupV1.getMessageExpirationTimer());
|
||||
statement.setBoolean(7, groupV1.isBlocked());
|
||||
statement.setBoolean(8, groupV1.archived);
|
||||
statement.executeUpdate();
|
||||
|
||||
if (internalId == null) {
|
||||
final var generatedKeys = statement.getGeneratedKeys();
|
||||
if (generatedKeys.next()) {
|
||||
internalId = generatedKeys.getLong(1);
|
||||
} else {
|
||||
throw new RuntimeException("Failed to add new recipient to database");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (modified) {
|
||||
storage = toStorageLocked();
|
||||
final var sqlInsertMember = """
|
||||
INSERT OR REPLACE INTO %s (group_id, recipient_id)
|
||||
VALUES (?, ?)
|
||||
""".formatted(TABLE_GROUP_V1_MEMBER);
|
||||
try (final var statement = connection.prepareStatement(sqlInsertMember)) {
|
||||
for (final var recipient : groupV1.getMembers()) {
|
||||
statement.setLong(1, internalId);
|
||||
statement.setLong(2, recipient.id());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
if (storage != null) {
|
||||
saver.save(storage);
|
||||
} else if (group instanceof GroupInfoV2 groupV2) {
|
||||
final var sql = (
|
||||
"""
|
||||
INSERT OR REPLACE INTO %s (_id, group_id, master_key, group_data, distribution_id, blocked, distribution_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
).formatted(TABLE_GROUP_V2);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
if (internalId == null) {
|
||||
statement.setNull(1, Types.NUMERIC);
|
||||
} else {
|
||||
statement.setLong(1, internalId);
|
||||
}
|
||||
statement.setBytes(2, groupV2.getGroupId().serialize());
|
||||
statement.setBytes(3, groupV2.getMasterKey().serialize());
|
||||
if (groupV2.getGroup() == null) {
|
||||
statement.setNull(4, Types.NUMERIC);
|
||||
} else {
|
||||
statement.setBytes(4, groupV2.getGroup().toByteArray());
|
||||
}
|
||||
statement.setBytes(5, UuidUtil.toByteArray(groupV2.getDistributionId().asUuid()));
|
||||
statement.setBoolean(6, groupV2.isBlocked());
|
||||
statement.setBoolean(7, groupV2.isPermissionDenied());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} else {
|
||||
throw new AssertionError("Invalid group id type");
|
||||
}
|
||||
}
|
||||
|
||||
private GroupInfo getGroupLocked(final GroupId groupId) {
|
||||
var group = groups.get(groupId);
|
||||
if (group == null) {
|
||||
if (groupId instanceof GroupIdV1) {
|
||||
group = getGroupByV1IdLocked((GroupIdV1) groupId);
|
||||
} else if (groupId instanceof GroupIdV2) {
|
||||
group = getGroupV1ByV2IdLocked((GroupIdV2) groupId);
|
||||
private List<GroupInfoV2> getGroupsV2() {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.permission_denied
|
||||
FROM %s g
|
||||
"""
|
||||
).formatted(TABLE_GROUP_V2);
|
||||
try (final var connection = database.getConnection()) {
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
return Utils.executeQueryForStream(statement, this::getGroupInfoV2FromResultSet)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed read from group store", e);
|
||||
}
|
||||
loadDecryptedGroupLocked(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
private GroupInfo getGroupByV1IdLocked(final GroupIdV1 groupId) {
|
||||
return groups.get(GroupUtils.getGroupIdV2(groupId));
|
||||
private GroupInfoV2 getGroup(Connection connection, GroupIdV2 groupIdV2) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.permission_denied
|
||||
FROM %s g
|
||||
WHERE g.group_id = ?
|
||||
"""
|
||||
).formatted(TABLE_GROUP_V2);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, groupIdV2.serialize());
|
||||
return Utils.executeQueryForOptional(statement, this::getGroupInfoV2FromResultSet).orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
private GroupInfoV1 getGroupV1ByV2IdLocked(GroupIdV2 groupIdV2) {
|
||||
for (var g : groups.values()) {
|
||||
if (g instanceof GroupInfoV1 gv1) {
|
||||
if (groupIdV2.equals(gv1.getExpectedV2Id())) {
|
||||
return gv1;
|
||||
}
|
||||
}
|
||||
}
|
||||
private GroupInfoV2 getGroupInfoV2FromResultSet(ResultSet resultSet) throws SQLException {
|
||||
try {
|
||||
final var groupId = resultSet.getBytes("group_id");
|
||||
final var masterKey = resultSet.getBytes("master_key");
|
||||
final var groupData = resultSet.getBytes("group_data");
|
||||
final var distributionId = resultSet.getBytes("distribution_id");
|
||||
final var blocked = resultSet.getBoolean("blocked");
|
||||
final var permissionDenied = resultSet.getBoolean("permission_denied");
|
||||
return new GroupInfoV2(GroupId.v2(groupId),
|
||||
new GroupMasterKey(masterKey),
|
||||
groupData == null ? null : DecryptedGroup.parseFrom(groupData),
|
||||
DistributionId.from(UuidUtil.parseOrThrow(distributionId)),
|
||||
blocked,
|
||||
permissionDenied,
|
||||
recipientResolver);
|
||||
} catch (InvalidInputException | InvalidProtocolBufferException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void loadDecryptedGroupLocked(final GroupInfo group) {
|
||||
if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() == null) {
|
||||
var groupFile = getGroupV2File(group.getGroupId());
|
||||
if (!groupFile.exists()) {
|
||||
groupFile = getGroupV2FileLegacy(group.getGroupId());
|
||||
}
|
||||
if (!groupFile.exists()) {
|
||||
return;
|
||||
}
|
||||
try (var stream = new FileInputStream(groupFile)) {
|
||||
((GroupInfoV2) group).setGroup(DecryptedGroup.parseFrom(stream), recipientResolver);
|
||||
} catch (IOException ignored) {
|
||||
private List<GroupInfoV1> getGroupsV1() {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived
|
||||
FROM %s g
|
||||
"""
|
||||
).formatted(TABLE_GROUP_V1_MEMBER, TABLE_GROUP_V1);
|
||||
try (final var connection = database.getConnection()) {
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
return Utils.executeQueryForStream(statement, this::getGroupInfoV1FromResultSet)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed read from group store", e);
|
||||
}
|
||||
}
|
||||
|
||||
private File getGroupV2FileLegacy(final GroupId groupId) {
|
||||
return new File(groupCachePath, Hex.toStringCondensed(groupId.serialize()));
|
||||
}
|
||||
|
||||
private File getGroupV2File(final GroupId groupId) {
|
||||
return new File(groupCachePath, groupId.toBase64().replace("/", "_"));
|
||||
}
|
||||
|
||||
private Storage toStorageLocked() {
|
||||
return new Storage(groups.values().stream().map(g -> {
|
||||
if (g instanceof GroupInfoV1 g1) {
|
||||
return new Storage.GroupV1(g1.getGroupId().toBase64(),
|
||||
g1.getExpectedV2Id().toBase64(),
|
||||
g1.name,
|
||||
g1.color,
|
||||
g1.messageExpirationTime,
|
||||
g1.blocked,
|
||||
g1.archived,
|
||||
g1.members.stream().map(m -> new Storage.GroupV1.Member(m.id(), null, null)).toList());
|
||||
}
|
||||
|
||||
final var g2 = (GroupInfoV2) g;
|
||||
return new Storage.GroupV2(g2.getGroupId().toBase64(),
|
||||
Base64.getEncoder().encodeToString(g2.getMasterKey().serialize()),
|
||||
g2.getDistributionId() == null ? null : g2.getDistributionId().toString(),
|
||||
g2.isBlocked(),
|
||||
g2.isPermissionDenied());
|
||||
}).toList());
|
||||
}
|
||||
|
||||
public record Storage(@JsonDeserialize(using = GroupsDeserializer.class) List<Record> groups) {
|
||||
|
||||
private record GroupV1(
|
||||
String groupId,
|
||||
String expectedV2Id,
|
||||
String name,
|
||||
String color,
|
||||
int messageExpirationTime,
|
||||
boolean blocked,
|
||||
boolean archived,
|
||||
@JsonDeserialize(using = MembersDeserializer.class) @JsonSerialize(using = MembersSerializer.class) List<Member> members
|
||||
) {
|
||||
|
||||
private record Member(Long recipientId, String uuid, String number) {}
|
||||
|
||||
private record JsonRecipientAddress(String uuid, String number) {}
|
||||
|
||||
private static class MembersSerializer extends JsonSerializer<List<Member>> {
|
||||
|
||||
@Override
|
||||
public void serialize(
|
||||
final List<Member> value, final JsonGenerator jgen, final SerializerProvider provider
|
||||
) throws IOException {
|
||||
jgen.writeStartArray(null, value.size());
|
||||
for (var address : value) {
|
||||
if (address.recipientId != null) {
|
||||
jgen.writeNumber(address.recipientId);
|
||||
} else if (address.uuid != null) {
|
||||
jgen.writeObject(new JsonRecipientAddress(address.uuid, address.number));
|
||||
} else {
|
||||
jgen.writeString(address.number);
|
||||
}
|
||||
}
|
||||
jgen.writeEndArray();
|
||||
private GroupInfoV1 getGroup(Connection connection, GroupIdV1 groupIdV1) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived
|
||||
FROM %s g
|
||||
WHERE g.group_id = ?
|
||||
"""
|
||||
).formatted(TABLE_GROUP_V1_MEMBER, TABLE_GROUP_V1);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, groupIdV1.serialize());
|
||||
return Utils.executeQueryForOptional(statement, this::getGroupInfoV1FromResultSet).orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static class MembersDeserializer extends JsonDeserializer<List<Member>> {
|
||||
|
||||
@Override
|
||||
public List<Member> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
var addresses = new ArrayList<Member>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (var n : node) {
|
||||
if (n.isTextual()) {
|
||||
addresses.add(new Member(null, null, n.textValue()));
|
||||
} else if (n.isNumber()) {
|
||||
addresses.add(new Member(n.numberValue().longValue(), null, null));
|
||||
} else {
|
||||
var address = jsonParser.getCodec().treeToValue(n, JsonRecipientAddress.class);
|
||||
addresses.add(new Member(null, address.uuid, address.number));
|
||||
}
|
||||
private GroupInfoV1 getGroupInfoV1FromResultSet(ResultSet resultSet) throws SQLException {
|
||||
final var groupId = resultSet.getBytes("group_id");
|
||||
final var groupIdV2 = resultSet.getBytes("group_id_v2");
|
||||
final var name = resultSet.getString("name");
|
||||
final var color = resultSet.getString("color");
|
||||
final var membersString = resultSet.getString("members");
|
||||
final var members = membersString == null
|
||||
? Set.<RecipientId>of()
|
||||
: Arrays.stream(membersString.split(","))
|
||||
.map(Integer::valueOf)
|
||||
.map(recipientIdCreator::create)
|
||||
.collect(Collectors.toSet());
|
||||
final var expirationTime = resultSet.getInt("expiration_time");
|
||||
final var blocked = resultSet.getBoolean("blocked");
|
||||
final var archived = resultSet.getBoolean("archived");
|
||||
return new GroupInfoV1(GroupId.v1(groupId),
|
||||
groupIdV2 == null ? null : GroupId.v2(groupIdV2),
|
||||
name,
|
||||
members,
|
||||
color,
|
||||
expirationTime,
|
||||
blocked,
|
||||
archived);
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
}
|
||||
private GroupInfoV2 getGroupV2ByV1Id(final Connection connection, final GroupIdV1 groupId) throws SQLException {
|
||||
return getGroup(connection, GroupUtils.getGroupIdV2(groupId));
|
||||
}
|
||||
|
||||
private record GroupV2(
|
||||
String groupId,
|
||||
String masterKey,
|
||||
String distributionId,
|
||||
@JsonInclude(JsonInclude.Include.NON_DEFAULT) boolean blocked,
|
||||
@JsonInclude(JsonInclude.Include.NON_DEFAULT) boolean permissionDenied
|
||||
) {}
|
||||
}
|
||||
|
||||
private static class GroupsDeserializer extends JsonDeserializer<List<Object>> {
|
||||
|
||||
@Override
|
||||
public List<Object> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
var groups = new ArrayList<>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (var n : node) {
|
||||
Object g;
|
||||
if (n.hasNonNull("masterKey")) {
|
||||
// a v2 group
|
||||
g = jsonParser.getCodec().treeToValue(n, Storage.GroupV2.class);
|
||||
} else {
|
||||
g = jsonParser.getCodec().treeToValue(n, Storage.GroupV1.class);
|
||||
}
|
||||
groups.add(g);
|
||||
}
|
||||
|
||||
return groups;
|
||||
private GroupInfoV1 getGroupV1ByV2Id(Connection connection, GroupIdV2 groupIdV2) throws SQLException {
|
||||
final var sql = (
|
||||
"""
|
||||
SELECT g.group_id, g.group_id_v2, g.name, g.color, (select group_concat(gm.recipient_id) from %s gm where gm.group_id = g._id) as members, g.expiration_time, g.blocked, g.archived
|
||||
FROM %s g
|
||||
WHERE g.group_id_v2 = ?
|
||||
"""
|
||||
).formatted(TABLE_GROUP_V1_MEMBER, TABLE_GROUP_V1);
|
||||
try (final var statement = connection.prepareStatement(sql)) {
|
||||
statement.setBytes(1, groupIdV2.serialize());
|
||||
return Utils.executeQueryForOptional(statement, this::getGroupInfoV1FromResultSet).orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
public interface Saver {
|
||||
|
||||
void save(Storage storage);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,202 @@
|
|||
package org.asamk.signal.manager.storage.groups;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
|
||||
import org.asamk.signal.manager.groups.GroupId;
|
||||
import org.asamk.signal.manager.groups.GroupIdV1;
|
||||
import org.asamk.signal.manager.groups.GroupIdV2;
|
||||
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
|
||||
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.signalservice.api.push.DistributionId;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.util.Hex;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class LegacyGroupStore {
|
||||
|
||||
private final static Logger logger = LoggerFactory.getLogger(LegacyGroupStore.class);
|
||||
|
||||
public static void migrate(
|
||||
final Storage storage,
|
||||
final File groupCachePath,
|
||||
final RecipientResolver recipientResolver,
|
||||
final GroupStore groupStore
|
||||
) {
|
||||
final var groups = storage.groups.stream().map(g -> {
|
||||
if (g instanceof Storage.GroupV1 g1) {
|
||||
final var members = g1.members.stream().map(m -> {
|
||||
if (m.recipientId == null) {
|
||||
return recipientResolver.resolveRecipient(new RecipientAddress(UuidUtil.parseOrNull(m.uuid),
|
||||
m.number));
|
||||
}
|
||||
|
||||
return recipientResolver.resolveRecipient(m.recipientId);
|
||||
}).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
|
||||
return new GroupInfoV1(GroupIdV1.fromBase64(g1.groupId),
|
||||
g1.expectedV2Id == null ? null : GroupIdV2.fromBase64(g1.expectedV2Id),
|
||||
g1.name,
|
||||
members,
|
||||
g1.color,
|
||||
g1.messageExpirationTime,
|
||||
g1.blocked,
|
||||
g1.archived);
|
||||
}
|
||||
|
||||
final var g2 = (Storage.GroupV2) g;
|
||||
var groupId = GroupIdV2.fromBase64(g2.groupId);
|
||||
GroupMasterKey masterKey;
|
||||
try {
|
||||
masterKey = new GroupMasterKey(Base64.getDecoder().decode(g2.masterKey));
|
||||
} catch (InvalidInputException | IllegalArgumentException e) {
|
||||
throw new AssertionError("Invalid master key for group " + groupId.toBase64());
|
||||
}
|
||||
|
||||
return new GroupInfoV2(groupId,
|
||||
masterKey,
|
||||
loadDecryptedGroupLocked(groupId, groupCachePath),
|
||||
g2.distributionId == null ? DistributionId.create() : DistributionId.from(g2.distributionId),
|
||||
g2.blocked,
|
||||
g2.permissionDenied,
|
||||
recipientResolver);
|
||||
}).toList();
|
||||
|
||||
groupStore.addLegacyGroups(groups);
|
||||
removeGroupCache(groupCachePath);
|
||||
}
|
||||
|
||||
private static void removeGroupCache(File groupCachePath) {
|
||||
final var files = groupCachePath.listFiles();
|
||||
if (files == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (var file : files) {
|
||||
try {
|
||||
Files.delete(file.toPath());
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to delete group cache file {}: {}", file, e.getMessage());
|
||||
}
|
||||
}
|
||||
try {
|
||||
Files.delete(groupCachePath.toPath());
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to delete group cache directory {}: {}", groupCachePath, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static DecryptedGroup loadDecryptedGroupLocked(final GroupIdV2 groupIdV2, final File groupCachePath) {
|
||||
var groupFile = getGroupV2File(groupIdV2, groupCachePath);
|
||||
if (!groupFile.exists()) {
|
||||
groupFile = getGroupV2FileLegacy(groupIdV2, groupCachePath);
|
||||
}
|
||||
if (!groupFile.exists()) {
|
||||
return null;
|
||||
}
|
||||
try (var stream = new FileInputStream(groupFile)) {
|
||||
return DecryptedGroup.parseFrom(stream);
|
||||
} catch (IOException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static File getGroupV2FileLegacy(final GroupId groupId, final File groupCachePath) {
|
||||
return new File(groupCachePath, Hex.toStringCondensed(groupId.serialize()));
|
||||
}
|
||||
|
||||
private static File getGroupV2File(final GroupId groupId, final File groupCachePath) {
|
||||
return new File(groupCachePath, groupId.toBase64().replace("/", "_"));
|
||||
}
|
||||
|
||||
public record Storage(@JsonDeserialize(using = GroupsDeserializer.class) List<Record> groups) {
|
||||
|
||||
private record GroupV1(
|
||||
String groupId,
|
||||
String expectedV2Id,
|
||||
String name,
|
||||
String color,
|
||||
int messageExpirationTime,
|
||||
boolean blocked,
|
||||
boolean archived,
|
||||
@JsonDeserialize(using = MembersDeserializer.class) List<Member> members
|
||||
) {
|
||||
|
||||
private record Member(Long recipientId, String uuid, String number) {}
|
||||
|
||||
private record JsonRecipientAddress(String uuid, String number) {}
|
||||
|
||||
private static class MembersDeserializer extends JsonDeserializer<List<Member>> {
|
||||
|
||||
@Override
|
||||
public List<Member> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
var addresses = new ArrayList<Member>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (var n : node) {
|
||||
if (n.isTextual()) {
|
||||
addresses.add(new Member(null, null, n.textValue()));
|
||||
} else if (n.isNumber()) {
|
||||
addresses.add(new Member(n.numberValue().longValue(), null, null));
|
||||
} else {
|
||||
var address = jsonParser.getCodec().treeToValue(n, JsonRecipientAddress.class);
|
||||
addresses.add(new Member(null, address.uuid, address.number));
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record GroupV2(
|
||||
String groupId,
|
||||
String masterKey,
|
||||
String distributionId,
|
||||
@JsonInclude(JsonInclude.Include.NON_DEFAULT) boolean blocked,
|
||||
@JsonInclude(JsonInclude.Include.NON_DEFAULT) boolean permissionDenied
|
||||
) {}
|
||||
}
|
||||
|
||||
private static class GroupsDeserializer extends JsonDeserializer<List<Object>> {
|
||||
|
||||
@Override
|
||||
public List<Object> deserialize(
|
||||
JsonParser jsonParser, DeserializationContext deserializationContext
|
||||
) throws IOException {
|
||||
var groups = new ArrayList<>();
|
||||
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
|
||||
for (var n : node) {
|
||||
Object g;
|
||||
if (n.hasNonNull("masterKey")) {
|
||||
// a v2 group
|
||||
g = jsonParser.getCodec().treeToValue(n, Storage.GroupV2.class);
|
||||
} else {
|
||||
g = jsonParser.getCodec().treeToValue(n, Storage.GroupV1.class);
|
||||
}
|
||||
groups.add(g);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue