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
.storageservice
.protos
.groups
.local
.DecryptedGroup
;
15 import org
.slf4j
.Logger
;
16 import org
.slf4j
.LoggerFactory
;
17 import org
.whispersystems
.signalservice
.api
.push
.DistributionId
;
18 import org
.whispersystems
.signalservice
.api
.util
.UuidUtil
;
20 import java
.io
.IOException
;
21 import java
.sql
.Connection
;
22 import java
.sql
.ResultSet
;
23 import java
.sql
.SQLException
;
24 import java
.sql
.Types
;
25 import java
.util
.Arrays
;
26 import java
.util
.Collection
;
27 import java
.util
.List
;
28 import java
.util
.Objects
;
30 import java
.util
.stream
.Collectors
;
31 import java
.util
.stream
.Stream
;
33 public class GroupStore
{
35 private static final Logger logger
= LoggerFactory
.getLogger(GroupStore
.class);
36 private static final String TABLE_GROUP_V2
= "group_v2";
37 private static final String TABLE_GROUP_V1
= "group_v1";
38 private static final String TABLE_GROUP_V1_MEMBER
= "group_v1_member";
40 private final Database database
;
41 private final RecipientResolver recipientResolver
;
42 private final RecipientIdCreator recipientIdCreator
;
44 public static void createSql(Connection connection
) throws SQLException
{
45 // When modifying the CREATE statement here, also add a migration in AccountDatabase.java
46 try (final var statement
= connection
.createStatement()) {
47 statement
.executeUpdate("""
48 CREATE TABLE group_v2 (
49 _id INTEGER PRIMARY KEY,
50 group_id BLOB UNIQUE NOT NULL,
51 master_key BLOB NOT NULL,
53 distribution_id BLOB UNIQUE NOT NULL,
54 blocked INTEGER NOT NULL DEFAULT FALSE,
55 permission_denied INTEGER NOT NULL DEFAULT FALSE
57 CREATE TABLE group_v1 (
58 _id INTEGER PRIMARY KEY,
59 group_id BLOB UNIQUE NOT NULL,
60 group_id_v2 BLOB UNIQUE,
63 expiration_time INTEGER NOT NULL DEFAULT 0,
64 blocked INTEGER NOT NULL DEFAULT FALSE,
65 archived INTEGER NOT NULL DEFAULT FALSE
67 CREATE TABLE group_v1_member (
68 _id INTEGER PRIMARY KEY,
69 group_id INTEGER NOT NULL REFERENCES group_v1 (_id) ON DELETE CASCADE,
70 recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
71 UNIQUE(group_id, recipient_id)
78 final Database database
,
79 final RecipientResolver recipientResolver
,
80 final RecipientIdCreator recipientIdCreator
82 this.database
= database
;
83 this.recipientResolver
= recipientResolver
;
84 this.recipientIdCreator
= recipientIdCreator
;
87 public void updateGroup(GroupInfo group
) {
88 try (final var connection
= database
.getConnection()) {
89 connection
.setAutoCommit(false);
90 final Long internalId
;
97 ).formatted(group
instanceof GroupInfoV1 ? TABLE_GROUP_V1
: TABLE_GROUP_V2
);
98 try (final var statement
= connection
.prepareStatement(sql
)) {
99 statement
.setBytes(1, group
.getGroupId().serialize());
100 internalId
= Utils
.executeQueryForOptional(statement
, res
-> res
.getLong("_id")).orElse(null);
102 insertOrReplaceGroup(connection
, internalId
, group
);
104 } catch (SQLException e
) {
105 throw new RuntimeException("Failed update recipient store", e
);
109 public void deleteGroup(GroupId groupId
) {
110 if (groupId
instanceof GroupIdV1 groupIdV1
) {
111 deleteGroup(groupIdV1
);
112 } else if (groupId
instanceof GroupIdV2 groupIdV2
) {
113 deleteGroup(groupIdV2
);
117 public void deleteGroup(GroupIdV1 groupIdV1
) {
123 ).formatted(TABLE_GROUP_V1
);
124 try (final var connection
= database
.getConnection()) {
125 try (final var statement
= connection
.prepareStatement(sql
)) {
126 statement
.setBytes(1, groupIdV1
.serialize());
127 statement
.executeUpdate();
129 } catch (SQLException e
) {
130 throw new RuntimeException("Failed update group store", e
);
134 public void deleteGroup(GroupIdV2 groupIdV2
) {
140 ).formatted(TABLE_GROUP_V2
);
141 try (final var connection
= database
.getConnection()) {
142 try (final var statement
= connection
.prepareStatement(sql
)) {
143 statement
.setBytes(1, groupIdV2
.serialize());
144 statement
.executeUpdate();
146 } catch (SQLException e
) {
147 throw new RuntimeException("Failed update group store", e
);
151 public GroupInfo
getGroup(GroupId groupId
) {
152 try (final var connection
= database
.getConnection()) {
153 return getGroup(connection
, groupId
);
154 } catch (SQLException e
) {
155 throw new RuntimeException("Failed read from group store", e
);
159 public GroupInfo
getGroup(final Connection connection
, final GroupId groupId
) throws SQLException
{
161 case GroupIdV1 groupIdV1
-> {
162 final var group
= getGroup(connection
, groupIdV1
);
166 return getGroupV2ByV1Id(connection
, groupIdV1
);
168 case GroupIdV2 groupIdV2
-> {
169 final var group
= getGroup(connection
, groupIdV2
);
173 return getGroupV1ByV2Id(connection
, groupIdV2
);
178 public GroupInfoV1
getOrCreateGroupV1(GroupIdV1 groupId
) {
179 try (final var connection
= database
.getConnection()) {
180 var group
= getGroup(connection
, groupId
);
186 if (getGroupV2ByV1Id(connection
, groupId
) == null) {
187 return new GroupInfoV1(groupId
);
191 } catch (SQLException e
) {
192 throw new RuntimeException("Failed read from group store", e
);
196 public List
<GroupInfo
> getGroups() {
197 return Stream
.concat(getGroupsV2().stream(), getGroupsV1().stream()).toList();
200 public void mergeRecipients(
201 final Connection connection
, final RecipientId recipientId
, final RecipientId toBeMergedRecipientId
202 ) throws SQLException
{
207 WHERE recipient_id = ?
209 ).formatted(TABLE_GROUP_V1_MEMBER
);
210 try (final var statement
= connection
.prepareStatement(sql
)) {
211 statement
.setLong(1, recipientId
.id());
212 statement
.setLong(2, toBeMergedRecipientId
.id());
213 final var updatedRows
= statement
.executeUpdate();
214 if (updatedRows
> 0) {
215 logger
.info("Updated {} group members when merging recipients", updatedRows
);
220 void addLegacyGroups(final Collection
<GroupInfo
> groups
) {
221 logger
.debug("Migrating legacy groups to database");
222 long start
= System
.nanoTime();
223 try (final var connection
= database
.getConnection()) {
224 connection
.setAutoCommit(false);
225 for (final var group
: groups
) {
226 insertOrReplaceGroup(connection
, null, group
);
229 } catch (SQLException e
) {
230 throw new RuntimeException("Failed update group store", e
);
232 logger
.debug("Complete groups migration took {}ms", (System
.nanoTime() - start
) / 1000000);
235 private void insertOrReplaceGroup(
236 final Connection connection
, Long internalId
, final GroupInfo group
237 ) throws SQLException
{
238 if (group
instanceof GroupInfoV1 groupV1
) {
239 if (internalId
!= null) {
240 final var sqlDeleteMembers
= "DELETE FROM %s where group_id = ?".formatted(TABLE_GROUP_V1_MEMBER
);
241 try (final var statement
= connection
.prepareStatement(sqlDeleteMembers
)) {
242 statement
.setLong(1, internalId
);
243 statement
.executeUpdate();
247 INSERT OR REPLACE INTO %s (_id, group_id, group_id_v2, name, color, expiration_time, blocked, archived)
248 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
250 """.formatted(TABLE_GROUP_V1
);
251 try (final var statement
= connection
.prepareStatement(sql
)) {
252 if (internalId
== null) {
253 statement
.setNull(1, Types
.NUMERIC
);
255 statement
.setLong(1, internalId
);
257 statement
.setBytes(2, groupV1
.getGroupId().serialize());
258 statement
.setBytes(3, groupV1
.getExpectedV2Id().serialize());
259 statement
.setString(4, groupV1
.getTitle());
260 statement
.setString(5, groupV1
.color
);
261 statement
.setLong(6, groupV1
.getMessageExpirationTimer());
262 statement
.setBoolean(7, groupV1
.isBlocked());
263 statement
.setBoolean(8, groupV1
.archived
);
264 final var generatedKey
= Utils
.executeQueryForOptional(statement
, Utils
::getIdMapper
);
266 if (internalId
== null) {
267 if (generatedKey
.isPresent()) {
268 internalId
= generatedKey
.get();
270 throw new RuntimeException("Failed to add new group to database");
274 final var sqlInsertMember
= """
275 INSERT OR REPLACE INTO %s (group_id, recipient_id)
277 """.formatted(TABLE_GROUP_V1_MEMBER
);
278 try (final var statement
= connection
.prepareStatement(sqlInsertMember
)) {
279 for (final var recipient
: groupV1
.getMembers()) {
280 statement
.setLong(1, internalId
);
281 statement
.setLong(2, recipient
.id());
282 statement
.executeUpdate();
285 } else if (group
instanceof GroupInfoV2 groupV2
) {
288 INSERT OR REPLACE INTO %s (_id, group_id, master_key, group_data, distribution_id, blocked, distribution_id)
289 VALUES (?, ?, ?, ?, ?, ?, ?)
291 ).formatted(TABLE_GROUP_V2
);
292 try (final var statement
= connection
.prepareStatement(sql
)) {
293 if (internalId
== null) {
294 statement
.setNull(1, Types
.NUMERIC
);
296 statement
.setLong(1, internalId
);
298 statement
.setBytes(2, groupV2
.getGroupId().serialize());
299 statement
.setBytes(3, groupV2
.getMasterKey().serialize());
300 if (groupV2
.getGroup() == null) {
301 statement
.setNull(4, Types
.NUMERIC
);
303 statement
.setBytes(4, groupV2
.getGroup().encode());
305 statement
.setBytes(5, UuidUtil
.toByteArray(groupV2
.getDistributionId().asUuid()));
306 statement
.setBoolean(6, groupV2
.isBlocked());
307 statement
.setBoolean(7, groupV2
.isPermissionDenied());
308 statement
.executeUpdate();
311 throw new AssertionError("Invalid group id type");
315 private List
<GroupInfoV2
> getGroupsV2() {
318 SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.permission_denied
321 ).formatted(TABLE_GROUP_V2
);
322 try (final var connection
= database
.getConnection()) {
323 try (final var statement
= connection
.prepareStatement(sql
)) {
324 return Utils
.executeQueryForStream(statement
, this::getGroupInfoV2FromResultSet
)
325 .filter(Objects
::nonNull
)
328 } catch (SQLException e
) {
329 throw new RuntimeException("Failed read from group store", e
);
333 private GroupInfoV2
getGroup(Connection connection
, GroupIdV2 groupIdV2
) throws SQLException
{
336 SELECT g.group_id, g.master_key, g.group_data, g.distribution_id, g.blocked, g.permission_denied
340 ).formatted(TABLE_GROUP_V2
);
341 try (final var statement
= connection
.prepareStatement(sql
)) {
342 statement
.setBytes(1, groupIdV2
.serialize());
343 return Utils
.executeQueryForOptional(statement
, this::getGroupInfoV2FromResultSet
).orElse(null);
347 private GroupInfoV2
getGroupInfoV2FromResultSet(ResultSet resultSet
) throws SQLException
{
349 final var groupId
= resultSet
.getBytes("group_id");
350 final var masterKey
= resultSet
.getBytes("master_key");
351 final var groupData
= resultSet
.getBytes("group_data");
352 final var distributionId
= resultSet
.getBytes("distribution_id");
353 final var blocked
= resultSet
.getBoolean("blocked");
354 final var permissionDenied
= resultSet
.getBoolean("permission_denied");
355 return new GroupInfoV2(GroupId
.v2(groupId
),
356 new GroupMasterKey(masterKey
),
357 groupData
== null ?
null : DecryptedGroup
.ADAPTER
.decode(groupData
),
358 DistributionId
.from(UuidUtil
.parseOrThrow(distributionId
)),
362 } catch (InvalidInputException
| IOException e
) {
367 private List
<GroupInfoV1
> getGroupsV1() {
370 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
373 ).formatted(TABLE_GROUP_V1_MEMBER
, TABLE_GROUP_V1
);
374 try (final var connection
= database
.getConnection()) {
375 try (final var statement
= connection
.prepareStatement(sql
)) {
376 return Utils
.executeQueryForStream(statement
, this::getGroupInfoV1FromResultSet
)
377 .filter(Objects
::nonNull
)
380 } catch (SQLException e
) {
381 throw new RuntimeException("Failed read from group store", e
);
385 private GroupInfoV1
getGroup(Connection connection
, GroupIdV1 groupIdV1
) throws SQLException
{
388 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
392 ).formatted(TABLE_GROUP_V1_MEMBER
, TABLE_GROUP_V1
);
393 try (final var statement
= connection
.prepareStatement(sql
)) {
394 statement
.setBytes(1, groupIdV1
.serialize());
395 return Utils
.executeQueryForOptional(statement
, this::getGroupInfoV1FromResultSet
).orElse(null);
399 private GroupInfoV1
getGroupInfoV1FromResultSet(ResultSet resultSet
) throws SQLException
{
400 final var groupId
= resultSet
.getBytes("group_id");
401 final var groupIdV2
= resultSet
.getBytes("group_id_v2");
402 final var name
= resultSet
.getString("name");
403 final var color
= resultSet
.getString("color");
404 final var membersString
= resultSet
.getString("members");
405 final var members
= membersString
== null
406 ? Set
.<RecipientId
>of()
407 : Arrays
.stream(membersString
.split(","))
408 .map(Integer
::valueOf
)
409 .map(recipientIdCreator
::create
)
410 .collect(Collectors
.toSet());
411 final var expirationTime
= resultSet
.getInt("expiration_time");
412 final var blocked
= resultSet
.getBoolean("blocked");
413 final var archived
= resultSet
.getBoolean("archived");
414 return new GroupInfoV1(GroupId
.v1(groupId
),
415 groupIdV2
== null ?
null : GroupId
.v2(groupIdV2
),
424 private GroupInfoV2
getGroupV2ByV1Id(final Connection connection
, final GroupIdV1 groupId
) throws SQLException
{
425 return getGroup(connection
, GroupUtils
.getGroupIdV2(groupId
));
428 private GroupInfoV1
getGroupV1ByV2Id(Connection connection
, GroupIdV2 groupIdV2
) throws SQLException
{
431 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
433 WHERE g.group_id_v2 = ?
435 ).formatted(TABLE_GROUP_V1_MEMBER
, TABLE_GROUP_V1
);
436 try (final var statement
= connection
.prepareStatement(sql
)) {
437 statement
.setBytes(1, groupIdV2
.serialize());
438 return Utils
.executeQueryForOptional(statement
, this::getGroupInfoV1FromResultSet
).orElse(null);