From 65c9a2e18572dbe6d8eab0c8f49fee223722c578 Mon Sep 17 00:00:00 2001 From: AsamK Date: Wed, 8 Jun 2022 23:29:30 +0200 Subject: [PATCH] Move group store to database --- graalvm-config-dir/reflect-config.json | 44 +- .../signal/manager/helper/GroupHelper.java | 18 +- .../signal/manager/helper/GroupV2Helper.java | 2 +- .../manager/storage/AccountDatabase.java | 36 +- .../signal/manager/storage/SignalAccount.java | 46 +- .../manager/storage/groups/GroupInfoV1.java | 4 +- .../manager/storage/groups/GroupInfoV2.java | 22 +- .../manager/storage/groups/GroupStore.java | 632 ++++++++++-------- .../storage/groups/LegacyGroupStore.java | 202 ++++++ 9 files changed, 633 insertions(+), 373 deletions(-) create mode 100644 lib/src/main/java/org/asamk/signal/manager/storage/groups/LegacyGroupStore.java diff --git a/graalvm-config-dir/reflect-config.json b/graalvm-config-dir/reflect-config.json index 62364e13..4942bda4 100644 --- a/graalvm-config-dir/reflect-config.json +++ b/graalvm-config-dir/reflect-config.json @@ -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":"","parameterTypes":[] }] }, { - "name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage", + "name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage", "allDeclaredFields":true, - "allDeclaredMethods":true, - "allDeclaredConstructors":true + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":["java.util.List"] }] }, { - "name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV1", + "name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage$GroupV1", "allDeclaredFields":true, - "allDeclaredMethods":true, - "allDeclaredConstructors":true + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","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.GroupStore$Storage$GroupV1$JsonRecipientAddress", + "name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage$GroupV1$JsonRecipientAddress", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true, - "methods":[ - {"name":"","parameterTypes":["java.lang.String","java.lang.String"] }, - {"name":"number","parameterTypes":[] }, - {"name":"uuid","parameterTypes":[] } - ] -}, -{ - "name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV1$MembersDeserializer", - "methods":[{"name":"","parameterTypes":[] }] + "methods":[{"name":"","parameterTypes":["java.lang.String","java.lang.String"] }] }, { - "name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV1$MembersSerializer", + "name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage$GroupV1$MembersDeserializer", "methods":[{"name":"","parameterTypes":[] }] }, { - "name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV2", + "name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage$GroupV2", "allDeclaredFields":true, - "allDeclaredMethods":true, - "allDeclaredConstructors":true + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","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":"","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":"","parameterTypes":[] }] diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java index 3ed642ed..5a262685 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java @@ -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); diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java b/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java index 967c70a2..b59eddcc 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java @@ -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); } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java b/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java index ed4008c3..93a145eb 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java @@ -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) + ); + """); + } + } } } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java index 792c2725..675c3f3e 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java @@ -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() { diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV1.java b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV1.java index 8b103976..ea5ae7be 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV1.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV1.java @@ -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 getMembers() { - return members; + return new HashSet<>(members); } @Override diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java index dc803f0f..fca71f10 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java @@ -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() { diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java index a9affdd6..e9db58f0 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java @@ -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 groups; + private final Database database; private final RecipientResolver recipientResolver; - private final Saver saver; - - private GroupStore( - final File groupCachePath, - final Map groups, - final RecipientResolver recipientResolver, - final Saver saver - ) { - this.groupCachePath = groupCachePath; - this.groups = groups; - this.recipientResolver = recipientResolver; - this.saver = saver; + private final RecipientIdCreator recipientIdCreator; + + 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 + final Database database, + final RecipientResolver recipientResolver, + final RecipientIdCreator recipientIdCreator ) { - this.groups = new HashMap<>(); - this.groupCachePath = groupCachePath; + this.database = database; this.recipientResolver = recipientResolver; - this.saver = saver; + this.recipientIdCreator = recipientIdCreator; } - public static GroupStore fromStorage( - final Storage storage, - final File groupCachePath, - final RecipientResolver recipientResolver, - final Saver saver - ) { - 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()); + public void updateGroup(GroupInfo group) { + 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); } - - 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); + insertOrReplaceGroup(connection, internalId, group); + connection.commit(); + } catch (SQLException e) { + throw new RuntimeException("Failed update recipient store", e); + } } - 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); - } - 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()); - } - } - } catch (IOException e) { - logger.warn("Failed to cache group, ignoring: {}", e.getMessage()); - } - } - storage = toStorageLocked(); + public void deleteGroup(GroupId groupId) { + if (groupId instanceof GroupIdV1 groupIdV1) { + deleteGroup(groupIdV1); + } else if (groupId instanceof GroupIdV2 groupIdV2) { + deleteGroup(groupIdV2); } - saver.save(storage); } - public void deleteGroupV1(GroupIdV1 groupIdV1) { - deleteGroup(groupIdV1); + 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(GroupId groupId) { - final Storage storage; - synchronized (groups) { - groups.remove(groupId); - storage = toStorageLocked(); + 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 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()) { - if (group instanceof GroupInfoV1 groupV1) { - if (groupV1.isMember(toBeMergedRecipientId)) { - groupV1.removeMember(toBeMergedRecipientId); - groupV1.addMembers(List.of(recipientId)); - modified = true; - } + 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); } } - if (modified) { - storage = toStorageLocked(); - } - } - if (storage != null) { - saver.save(storage); + } catch (SQLException e) { + throw new RuntimeException("Failed update group store", e); } } - 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); + void addLegacyGroups(final Collection 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); } - loadDecryptedGroupLocked(group); - return group; - } - - private GroupInfo getGroupByV1IdLocked(final GroupIdV1 groupId) { - return groups.get(GroupUtils.getGroupIdV2(groupId)); + logger.debug("Complete groups migration took {}ms", (System.nanoTime() - start) / 1000000); } - private GroupInfoV1 getGroupV1ByV2IdLocked(GroupIdV2 groupIdV2) { - for (var g : groups.values()) { - if (g instanceof GroupInfoV1 gv1) { - if (groupIdV2.equals(gv1.getExpectedV2Id())) { - return gv1; + private void insertOrReplaceGroup( + final Connection connection, Long internalId, final GroupInfo group + ) throws SQLException { + if (group instanceof GroupInfoV1 groupV1) { + 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(); } } - } - 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()); + 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 (!groupFile.exists()) { - return; + 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(); + } } - try (var stream = new FileInputStream(groupFile)) { - ((GroupInfoV2) group).setGroup(DecryptedGroup.parseFrom(stream), recipientResolver); - } catch (IOException ignored) { + } 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 File getGroupV2FileLegacy(final GroupId groupId) { - return new File(groupCachePath, Hex.toStringCondensed(groupId.serialize())); + private List 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); + } } - private File getGroupV2File(final GroupId groupId) { - return new File(groupCachePath, groupId.toBase64().replace("/", "_")); + 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 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()); + 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; + } } - public record Storage(@JsonDeserialize(using = GroupsDeserializer.class) List 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 members - ) { - - private record Member(Long recipientId, String uuid, String number) {} - - private record JsonRecipientAddress(String uuid, String number) {} - - private static class MembersSerializer extends JsonSerializer> { - - @Override - public void serialize( - final List 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 static class MembersDeserializer extends JsonDeserializer> { - - @Override - public List deserialize( - JsonParser jsonParser, DeserializationContext deserializationContext - ) throws IOException { - var addresses = new ArrayList(); - 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 List 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 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> { - - @Override - public List 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 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); } } - public interface Saver { + 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.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); + } - void save(Storage storage); + private GroupInfoV2 getGroupV2ByV1Id(final Connection connection, final GroupIdV1 groupId) throws SQLException { + return getGroup(connection, GroupUtils.getGroupIdV2(groupId)); + } + + 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); + } } } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/groups/LegacyGroupStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/groups/LegacyGroupStore.java new file mode 100644 index 00000000..0b5a7d4b --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/storage/groups/LegacyGroupStore.java @@ -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 groups) { + + private record GroupV1( + String groupId, + String expectedV2Id, + String name, + String color, + int messageExpirationTime, + boolean blocked, + boolean archived, + @JsonDeserialize(using = MembersDeserializer.class) List members + ) { + + private record Member(Long recipientId, String uuid, String number) {} + + private record JsonRecipientAddress(String uuid, String number) {} + + private static class MembersDeserializer extends JsonDeserializer> { + + @Override + public List deserialize( + JsonParser jsonParser, DeserializationContext deserializationContext + ) throws IOException { + var addresses = new ArrayList(); + 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> { + + @Override + public List 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; + } + } +} -- 2.50.1