Move group store to database

This commit is contained in:
AsamK 2022-06-08 23:29:30 +02:00
parent 46adc1af98
commit 65c9a2e185
9 changed files with 659 additions and 399 deletions

View file

@ -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":[] }]

View file

@ -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);

View file

@ -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);
}

View file

@ -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)
);
""");
}
}
}
}

View file

@ -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() {

View file

@ -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

View file

@ -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() {

View file

@ -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);
}
}

View file

@ -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;
}
}
}