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