]> nmode's Git Repositories - signal-cli/commitdiff
Move group store to database
authorAsamK <asamk@gmx.de>
Wed, 8 Jun 2022 21:29:30 +0000 (23:29 +0200)
committerAsamK <asamk@gmx.de>
Sun, 28 Aug 2022 14:04:05 +0000 (16:04 +0200)
graalvm-config-dir/reflect-config.json
lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java
lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java
lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java
lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV1.java
lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupInfoV2.java
lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java
lib/src/main/java/org/asamk/signal/manager/storage/groups/LegacyGroupStore.java [new file with mode: 0644]

index 62364e130ab01ecef8ac0470aef1e2666bfe8817..4942bda4b68fc0602d7d2bc259e08ca2a5843946 100644 (file)
   "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",
+  "name":"org.asamk.signal.manager.storage.groups.LegacyGroupStore$Storage",
   "allDeclaredFields":true,
-  "allDeclaredMethods":true,
-  "allDeclaredConstructors":true
+  "queryAllDeclaredMethods":true,
+  "queryAllDeclaredConstructors":true,
+  "methods":[{"name":"<init>","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":"<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.GroupStore$Storage$GroupV1$JsonRecipientAddress",
+  "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":"number","parameterTypes":[] }, 
-    {"name":"uuid","parameterTypes":[] }
-  ]
-},
-{
-  "name":"org.asamk.signal.manager.storage.groups.GroupStore$Storage$GroupV1$MembersDeserializer",
-  "methods":[{"name":"<init>","parameterTypes":[] }]
+  "methods":[{"name":"<init>","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":"<init>","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":"<init>","parameterTypes":["java.lang.String","java.lang.String","java.lang.String","boolean","boolean"] }]
 },
 {
   "name":"org.asamk.signal.manager.storage.identities.IdentityKeyStore$IdentityStorage",
   "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":[] }]
index 3ed642ede0af87bafc43e672f7cf31c074866bbd..5a262685ec294513a8eb8336ea9462630fd4b727 100644 (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);
 
index 967c70a235f7da6d77dc40a5eafc504fa53c4496..b59eddcc2cf9e834cf921d9fd81fb1e7c63dcce5 100644 (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);
     }
index ed4008c30ab27fd2a7b53c55baf598cc6f477bf4..93a145ebf6b60218906b296aa527dc1dddad9bfd 100644 (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)
+                                        );
+                                        """);
+            }
+        }
     }
 }
index 792c272573e1ce69761d588d86535ed492ecce61..675c3f3e3711e0c0d5de32be7a5c741f18c471b0 100644 (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() {
index 8b10397619d117988be4d5a5c3aaa2effb1c61d9..ea5ae7be0a15a13b996495d02529a7e774e56e74 100644 (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
index dc803f0f99195b4702e487831da1a3bcaf0632c4..fca71f10e2e001e82aebe4dfe2aaf4a29105c793 100644 (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() {
index a9affdd6d9604c09ae81c066a37c5ecaf1ad0e62..e9db58f0ea42e7a421d9274bda4c4b9c277f0428 100644 (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 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;
+    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<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()) {
-                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<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);
         }
-        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<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);
+        }
     }
 
-    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<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 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 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 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 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.<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);
+    }
 
-        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 (file)
index 0000000..0b5a7d4
--- /dev/null
@@ -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;
+        }
+    }
+}