1 package org
.asamk
.signal
.manager
.storage
.groups
;
3 import org
.asamk
.signal
.manager
.api
.GroupId
;
4 import org
.asamk
.signal
.manager
.api
.GroupIdV1
;
5 import org
.asamk
.signal
.manager
.api
.GroupIdV2
;
6 import org
.asamk
.signal
.manager
.groups
.GroupUtils
;
7 import org
.asamk
.signal
.manager
.storage
.Database
;
8 import org
.asamk
.signal
.manager
.storage
.Utils
;
9 import org
.asamk
.signal
.manager
.storage
.recipients
.RecipientId
;
10 import org
.asamk
.signal
.manager
.storage
.recipients
.RecipientIdCreator
;
11 import org
.asamk
.signal
.manager
.storage
.recipients
.RecipientResolver
;
12 import org
.signal
.libsignal
.zkgroup
.InvalidInputException
;
13 import org
.signal
.libsignal
.zkgroup
.groups
.GroupMasterKey
;
14 import org
.signal
.libsignal
.zkgroup
.groups
.GroupSecretParams
;
15 import org
.signal
.storageservice
.protos
.groups
.local
.DecryptedGroup
;
16 import org
.slf4j
.Logger
;
17 import org
.slf4j
.LoggerFactory
;
18 import org
.whispersystems
.signalservice
.api
.push
.DistributionId
;
19 import org
.whispersystems
.signalservice
.api
.util
.UuidUtil
;
21 import java
.io
.IOException
;
22 import java
.sql
.Connection
;
23 import java
.sql
.ResultSet
;
24 import java
.sql
.SQLException
;
25 import java
.sql
.Types
;
26 import java
.util
.Arrays
;
27 import java
.util
.Collection
;
28 import java
.util
.List
;
29 import java
.util
.Objects
;
31 import java
.util
.stream
.Collectors
;
32 import java
.util
.stream
.Stream
;
34 public class GroupStore
{
36 private static final Logger logger
= LoggerFactory
.getLogger(GroupStore
.class);
37 private static final String TABLE_GROUP_V2
= "group_v2";
38 private static final String TABLE_GROUP_V1
= "group_v1";
39 private static final String TABLE_GROUP_V1_MEMBER
= "group_v1_member";
41 private final Database database
;
42 private final RecipientResolver recipientResolver
;
43 private final RecipientIdCreator recipientIdCreator
;
45 public static void createSql(Connection connection
) throws SQLException
{
46 // When modifying the CREATE statement here, also add a migration in AccountDatabase.java
47 try (final var statement
= connection
.createStatement()) {
48 statement
.executeUpdate("""
49 CREATE TABLE group_v2 (
50 _id INTEGER PRIMARY KEY,
51 group_id BLOB UNIQUE NOT NULL,
52 master_key BLOB NOT NULL,
54 distribution_id BLOB UNIQUE NOT NULL,
55 blocked INTEGER NOT NULL DEFAULT FALSE,
56 permission_denied INTEGER NOT NULL DEFAULT FALSE
58 CREATE TABLE group_v1 (
59 _id INTEGER PRIMARY KEY,
60 group_id BLOB UNIQUE NOT NULL,
61 group_id_v2 BLOB UNIQUE,
64 expiration_time INTEGER NOT NULL DEFAULT 0,
65 blocked INTEGER NOT NULL DEFAULT FALSE,
66 archived INTEGER NOT NULL DEFAULT FALSE
68 CREATE TABLE group_v1_member (
69 _id INTEGER PRIMARY KEY,
70 group_id INTEGER NOT NULL REFERENCES group_v1 (_id) ON DELETE CASCADE,
71 recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
72 UNIQUE(group_id, recipient_id)
79 final Database database
,
80 final RecipientResolver recipientResolver
,
81 final RecipientIdCreator recipientIdCreator
83 this.database
= database
;
84 this.recipientResolver
= recipientResolver
;
85 this.recipientIdCreator
= recipientIdCreator
;
88 public void updateGroup(GroupInfo group
) {
89 try (final var connection
= database
.getConnection()) {
90 connection
.setAutoCommit(false);
91 updateGroup(connection
, group
);
93 } catch (SQLException e
) {
94 throw new RuntimeException("Failed update recipient store", e
);
98 public void updateGroup(final Connection connection
, final GroupInfo group
) throws SQLException
{
99 final Long internalId
;
106 ).formatted(group
instanceof GroupInfoV1 ? TABLE_GROUP_V1
: TABLE_GROUP_V2
);
107 try (final var statement
= connection
.prepareStatement(sql
)) {
108 statement
.setBytes(1, group
.getGroupId().serialize());
109 internalId
= Utils
.executeQueryForOptional(statement
, res
-> res
.getLong("_id")).orElse(null);
111 insertOrReplaceGroup(connection
, internalId
, group
);
114 public void deleteGroup(GroupId groupId
) {
115 if (groupId
instanceof GroupIdV1 groupIdV1
) {
116 deleteGroup(groupIdV1
);
117 } else if (groupId
instanceof GroupIdV2 groupIdV2
) {
118 deleteGroup(groupIdV2
);
122 public void deleteGroup(GroupIdV1 groupIdV1
) {
123 try (final var connection
= database
.getConnection()) {
124 deleteGroup(connection
, groupIdV1
);
125 } catch (SQLException e
) {
126 throw new RuntimeException("Failed update group store", e
);
130 private void deleteGroup(final Connection connection
, final GroupIdV1 groupIdV1
) throws SQLException
{
136 ).formatted(TABLE_GROUP_V1
);
137 try (final var statement
= connection
.prepareStatement(sql
)) {
138 statement
.setBytes(1, groupIdV1
.serialize());
139 statement
.executeUpdate();
143 public void deleteGroup(GroupIdV2 groupIdV2
) {
144 try (final var connection
= database
.getConnection()) {
150 ).formatted(TABLE_GROUP_V2
);
151 try (final var statement
= connection
.prepareStatement(sql
)) {
152 statement
.setBytes(1, groupIdV2
.serialize());
153 statement
.executeUpdate();
155 } catch (SQLException e
) {
156 throw new RuntimeException("Failed update group store", e
);
160 public GroupInfo
getGroup(GroupId groupId
) {
161 try (final var connection
= database
.getConnection()) {
162 return getGroup(connection
, groupId
);
163 } catch (SQLException e
) {
164 throw new RuntimeException("Failed read from group store", e
);
168 public GroupInfo
getGroup(final Connection connection
, final GroupId groupId
) throws SQLException
{
170 case GroupIdV1 groupIdV1
-> {
171 final var group
= getGroup(connection
, groupIdV1
);
175 return getGroupV2ByV1Id(connection
, groupIdV1
);
177 case GroupIdV2 groupIdV2
-> {
178 final var group
= getGroup(connection
, groupIdV2
);
182 return getGroupV1ByV2Id(connection
, groupIdV2
);
187 public GroupInfoV1
getOrCreateGroupV1(GroupIdV1 groupId
) {
188 try (final var connection
= database
.getConnection()) {
189 var group
= getGroup(connection
, groupId
);
195 if (getGroupV2ByV1Id(connection
, groupId
) == null) {
196 return new GroupInfoV1(groupId
);
200 } catch (SQLException e
) {
201 throw new RuntimeException("Failed read from group store", e
);
205 public GroupInfoV2
getGroupOrPartialMigrate(
206 Connection connection
, final GroupMasterKey groupMasterKey
207 ) throws SQLException
{
208 final var groupSecretParams
= GroupSecretParams
.deriveFromMasterKey(groupMasterKey
);
209 final var groupId
= GroupUtils
.getGroupIdV2(groupSecretParams
);
211 return getGroupOrPartialMigrate(connection
, groupMasterKey
, groupId
);
214 public GroupInfoV2
getGroupOrPartialMigrate(
215 final GroupMasterKey groupMasterKey
, final GroupIdV2 groupId
217 try (final var connection
= database
.getConnection()) {
218 return getGroupOrPartialMigrate(connection
, groupMasterKey
, groupId
);
219 } catch (SQLException e
) {
220 throw new RuntimeException("Failed read from group store", e
);
224 private GroupInfoV2
getGroupOrPartialMigrate(
225 Connection connection
, final GroupMasterKey groupMasterKey
, final GroupIdV2 groupId
226 ) throws SQLException
{
227 switch (getGroup(groupId
)) {
228 case GroupInfoV1 groupInfoV1
-> {
229 // Received a v2 group message for a v1 group, we need to locally migrate the group
230 deleteGroup(connection
, groupInfoV1
.getGroupId());
231 final var groupInfoV2
= new GroupInfoV2(groupId
, groupMasterKey
, recipientResolver
);
232 groupInfoV2
.setBlocked(groupInfoV1
.isBlocked());
233 updateGroup(connection
, groupInfoV2
);
234 logger
.debug("Locally migrated group {} to group v2, id: {}",
235 groupInfoV1
.getGroupId().toBase64(),
236 groupInfoV2
.getGroupId().toBase64());
239 case GroupInfoV2 groupInfoV2
-> {
243 return new GroupInfoV2(groupId
, groupMasterKey
, recipientResolver
);
248 public List
<GroupInfo
> getGroups() {
249 return Stream
.concat(getGroupsV2().stream(), getGroupsV1().stream()).toList();
252 public void mergeRecipients(
253 final Connection connection
, final RecipientId recipientId
, final RecipientId toBeMergedRecipientId
254 ) throws SQLException
{
259 WHERE recipient_id = ?
261 ).formatted(TABLE_GROUP_V1_MEMBER
);
262 try (final var statement
= connection
.prepareStatement(sql
)) {
263 statement
.setLong(1, recipientId
.id());
264 statement
.setLong(2, toBeMergedRecipientId
.id());
265 final var updatedRows
= statement
.executeUpdate();
266 if (updatedRows
> 0) {
267 logger
.debug("Updated {} group members when merging recipients", updatedRows
);
272 void addLegacyGroups(final Collection
<GroupInfo
> groups
) {
273 logger
.debug("Migrating legacy groups to database");
274 long start
= System
.nanoTime();
275 try (final var connection
= database
.getConnection()) {
276 connection
.setAutoCommit(false);
277 for (final var group
: groups
) {
278 insertOrReplaceGroup(connection
, null, group
);
281 } catch (SQLException e
) {
282 throw new RuntimeException("Failed update group store", e
);
284 logger
.debug("Complete groups migration took {}ms", (System
.nanoTime() - start
) / 1000000);
287 private void insertOrReplaceGroup(
288 final Connection connection
, Long internalId
, final GroupInfo group
289 ) throws SQLException
{
290 if (group
instanceof GroupInfoV1 groupV1
) {
291 if (internalId
!= null) {
292 final var sqlDeleteMembers
= "DELETE FROM %s where group_id = ?".formatted(TABLE_GROUP_V1_MEMBER
);
293 try (final var statement
= connection
.prepareStatement(sqlDeleteMembers
)) {
294 statement
.setLong(1, internalId
);
295 statement
.executeUpdate();
299 INSERT OR REPLACE INTO %s (_id, group_id, group_id_v2, name, color, expiration_time, blocked, archived)
300 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
302 """.formatted(TABLE_GROUP_V1
);
303 try (final var statement
= connection
.prepareStatement(sql
)) {
304 if (internalId
== null) {
305 statement
.setNull(1, Types
.NUMERIC
);
307 statement
.setLong(1, internalId
);
309 statement
.setBytes(2, groupV1
.getGroupId().serialize());
310 statement
.setBytes(3, groupV1
.getExpectedV2Id().serialize());
311 statement
.setString(4, groupV1
.getTitle());
312 statement
.setString(5, groupV1
.color
);
313 statement
.setLong(6, groupV1
.getMessageExpirationTimer());
314 statement
.setBoolean(7, groupV1
.isBlocked());
315 statement
.setBoolean(8, groupV1
.archived
);
316 final var generatedKey
= Utils
.executeQueryForOptional(statement
, Utils
::getIdMapper
);
318 if (internalId
== null) {
319 if (generatedKey
.isPresent()) {
320 internalId
= generatedKey
.get();
322 throw new RuntimeException("Failed to add new group to database");
326 final var sqlInsertMember
= """
327 INSERT OR REPLACE INTO %s (group_id, recipient_id)
329 """.formatted(TABLE_GROUP_V1_MEMBER
);
330 try (final var statement
= connection
.prepareStatement(sqlInsertMember
)) {
331 for (final var recipient
: groupV1
.getMembers()) {
332 statement
.setLong(1, internalId
);
333 statement
.setLong(2, recipient
.id());
334 statement
.executeUpdate();
337 } else if (group
instanceof GroupInfoV2 groupV2
) {
340 INSERT OR REPLACE INTO %s (_id, group_id, master_key, group_data, distribution_id, blocked, distribution_id)
341 VALUES (?, ?, ?, ?, ?, ?, ?)
343 ).formatted(TABLE_GROUP_V2
);
344 try (final var statement
= connection
.prepareStatement(sql
)) {
345 if (internalId
== null) {
346 statement
.setNull(1, Types
.NUMERIC
);
348 statement
.setLong(1, internalId
);
350 statement
.setBytes(2, groupV2
.getGroupId().serialize());
351 statement
.setBytes(3, groupV2
.getMasterKey().serialize());
352 if (groupV2
.getGroup() == null) {
353 statement
.setNull(4, Types
.NUMERIC
);
355 statement
.setBytes(4, groupV2
.getGroup().encode());
357 statement
.setBytes(5, UuidUtil
.toByteArray(groupV2
.getDistributionId().asUuid()));
358 statement
.setBoolean(6, groupV2
.isBlocked());
359 statement
.setBoolean(7, groupV2
.isPermissionDenied());
360 statement
.executeUpdate();
363 throw new AssertionError("Invalid group id type");
367 private List
<GroupInfoV2
> getGroupsV2() {
370 SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.permission_denied
373 ).formatted(TABLE_GROUP_V2
);
374 try (final var connection
= database
.getConnection()) {
375 try (final var statement
= connection
.prepareStatement(sql
)) {
376 return Utils
.executeQueryForStream(statement
, this::getGroupInfoV2FromResultSet
)
377 .filter(Objects
::nonNull
)
380 } catch (SQLException e
) {
381 throw new RuntimeException("Failed read from group store", e
);
385 private GroupInfoV2
getGroup(Connection connection
, GroupIdV2 groupIdV2
) throws SQLException
{
388 SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.permission_denied
392 ).formatted(TABLE_GROUP_V2
);
393 try (final var statement
= connection
.prepareStatement(sql
)) {
394 statement
.setBytes(1, groupIdV2
.serialize());
395 return Utils
.executeQueryForOptional(statement
, this::getGroupInfoV2FromResultSet
).orElse(null);
399 private GroupInfoV2
getGroupInfoV2FromResultSet(ResultSet resultSet
) throws SQLException
{
401 final var groupId
= resultSet
.getBytes("group_id");
402 final var masterKey
= resultSet
.getBytes("master_key");
403 final var groupData
= resultSet
.getBytes("group_data");
404 final var distributionId
= resultSet
.getBytes("distribution_id");
405 final var blocked
= resultSet
.getBoolean("blocked");
406 final var permissionDenied
= resultSet
.getBoolean("permission_denied");
407 return new GroupInfoV2(GroupId
.v2(groupId
),
408 new GroupMasterKey(masterKey
),
409 groupData
== null ?
null : DecryptedGroup
.ADAPTER
.decode(groupData
),
410 DistributionId
.from(UuidUtil
.parseOrThrow(distributionId
)),
414 } catch (InvalidInputException
| IOException e
) {
419 private List
<GroupInfoV1
> getGroupsV1() {
422 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
425 ).formatted(TABLE_GROUP_V1_MEMBER
, TABLE_GROUP_V1
);
426 try (final var connection
= database
.getConnection()) {
427 try (final var statement
= connection
.prepareStatement(sql
)) {
428 return Utils
.executeQueryForStream(statement
, this::getGroupInfoV1FromResultSet
)
429 .filter(Objects
::nonNull
)
432 } catch (SQLException e
) {
433 throw new RuntimeException("Failed read from group store", e
);
437 private GroupInfoV1
getGroup(Connection connection
, GroupIdV1 groupIdV1
) throws SQLException
{
440 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
444 ).formatted(TABLE_GROUP_V1_MEMBER
, TABLE_GROUP_V1
);
445 try (final var statement
= connection
.prepareStatement(sql
)) {
446 statement
.setBytes(1, groupIdV1
.serialize());
447 return Utils
.executeQueryForOptional(statement
, this::getGroupInfoV1FromResultSet
).orElse(null);
451 private GroupInfoV1
getGroupInfoV1FromResultSet(ResultSet resultSet
) throws SQLException
{
452 final var groupId
= resultSet
.getBytes("group_id");
453 final var groupIdV2
= resultSet
.getBytes("group_id_v2");
454 final var name
= resultSet
.getString("name");
455 final var color
= resultSet
.getString("color");
456 final var membersString
= resultSet
.getString("members");
457 final var members
= membersString
== null
458 ? Set
.<RecipientId
>of()
459 : Arrays
.stream(membersString
.split(","))
460 .map(Integer
::valueOf
)
461 .map(recipientIdCreator
::create
)
462 .collect(Collectors
.toSet());
463 final var expirationTime
= resultSet
.getInt("expiration_time");
464 final var blocked
= resultSet
.getBoolean("blocked");
465 final var archived
= resultSet
.getBoolean("archived");
466 return new GroupInfoV1(GroupId
.v1(groupId
),
467 groupIdV2
== null ?
null : GroupId
.v2(groupIdV2
),
476 private GroupInfoV2
getGroupV2ByV1Id(final Connection connection
, final GroupIdV1 groupId
) throws SQLException
{
477 return getGroup(connection
, GroupUtils
.getGroupIdV2(groupId
));
480 private GroupInfoV1
getGroupV1ByV2Id(Connection connection
, GroupIdV2 groupIdV2
) throws SQLException
{
483 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
485 WHERE g.group_id_v2 = ?
487 ).formatted(TABLE_GROUP_V1_MEMBER
, TABLE_GROUP_V1
);
488 try (final var statement
= connection
.prepareStatement(sql
)) {
489 statement
.setBytes(1, groupIdV2
.serialize());
490 return Utils
.executeQueryForOptional(statement
, this::getGroupInfoV1FromResultSet
).orElse(null);