1 package org
.asamk
.signal
.manager
.storage
.groups
;
3 import com
.fasterxml
.jackson
.annotation
.JsonInclude
;
4 import com
.fasterxml
.jackson
.core
.JsonParser
;
5 import com
.fasterxml
.jackson
.databind
.DeserializationContext
;
6 import com
.fasterxml
.jackson
.databind
.JsonDeserializer
;
7 import com
.fasterxml
.jackson
.databind
.JsonNode
;
8 import com
.fasterxml
.jackson
.databind
.annotation
.JsonDeserialize
;
10 import org
.asamk
.signal
.manager
.api
.GroupId
;
11 import org
.asamk
.signal
.manager
.api
.GroupIdV1
;
12 import org
.asamk
.signal
.manager
.api
.GroupIdV2
;
13 import org
.asamk
.signal
.manager
.storage
.recipients
.RecipientAddress
;
14 import org
.asamk
.signal
.manager
.storage
.recipients
.RecipientResolver
;
15 import org
.signal
.libsignal
.zkgroup
.InvalidInputException
;
16 import org
.signal
.libsignal
.zkgroup
.groups
.GroupMasterKey
;
17 import org
.signal
.storageservice
.protos
.groups
.local
.DecryptedGroup
;
18 import org
.slf4j
.Logger
;
19 import org
.slf4j
.LoggerFactory
;
20 import org
.whispersystems
.signalservice
.api
.push
.DistributionId
;
21 import org
.whispersystems
.signalservice
.api
.push
.ServiceId
;
22 import org
.whispersystems
.signalservice
.internal
.util
.Hex
;
25 import java
.io
.FileInputStream
;
26 import java
.io
.IOException
;
27 import java
.nio
.file
.Files
;
28 import java
.util
.ArrayList
;
29 import java
.util
.Base64
;
30 import java
.util
.List
;
31 import java
.util
.Objects
;
32 import java
.util
.stream
.Collectors
;
34 public class LegacyGroupStore
{
36 private static final Logger logger
= LoggerFactory
.getLogger(LegacyGroupStore
.class);
38 public static void migrate(
39 final Storage storage
,
40 final File groupCachePath
,
41 final RecipientResolver recipientResolver
,
42 final GroupStore groupStore
44 final var groups
= storage
.groups
.stream().map(g
-> {
45 if (g
instanceof Storage
.GroupV1 g1
) {
46 final var members
= g1
.members
.stream().map(m
-> {
47 if (m
.recipientId
== null) {
48 return recipientResolver
.resolveRecipient(new RecipientAddress(ServiceId
.ACI
.parseOrNull(m
.uuid
),
52 return recipientResolver
.resolveRecipient(m
.recipientId
);
53 }).filter(Objects
::nonNull
).collect(Collectors
.toSet());
55 return new GroupInfoV1(GroupIdV1
.fromBase64(g1
.groupId
),
56 g1
.expectedV2Id
== null ?
null : GroupIdV2
.fromBase64(g1
.expectedV2Id
),
60 g1
.messageExpirationTime
,
66 final var g2
= (Storage
.GroupV2
) g
;
67 var groupId
= GroupIdV2
.fromBase64(g2
.groupId
);
68 GroupMasterKey masterKey
;
70 masterKey
= new GroupMasterKey(Base64
.getDecoder().decode(g2
.masterKey
));
71 } catch (InvalidInputException
| IllegalArgumentException e
) {
72 throw new AssertionError("Invalid master key for group " + groupId
.toBase64());
75 return new GroupInfoV2(groupId
,
77 loadDecryptedGroupLocked(groupId
, groupCachePath
),
78 g2
.distributionId
== null ? DistributionId
.create() : DistributionId
.from(g2
.distributionId
),
85 groupStore
.addLegacyGroups(groups
);
86 removeGroupCache(groupCachePath
);
89 private static void removeGroupCache(File groupCachePath
) {
90 final var files
= groupCachePath
.listFiles();
95 for (var file
: files
) {
97 Files
.delete(file
.toPath());
98 } catch (IOException e
) {
99 logger
.error("Failed to delete group cache file {}: {}", file
, e
.getMessage());
103 Files
.delete(groupCachePath
.toPath());
104 } catch (IOException e
) {
105 logger
.error("Failed to delete group cache directory {}: {}", groupCachePath
, e
.getMessage());
109 private static DecryptedGroup
loadDecryptedGroupLocked(final GroupIdV2 groupIdV2
, final File groupCachePath
) {
110 var groupFile
= getGroupV2File(groupIdV2
, groupCachePath
);
111 if (!groupFile
.exists()) {
112 groupFile
= getGroupV2FileLegacy(groupIdV2
, groupCachePath
);
114 if (!groupFile
.exists()) {
117 try (var stream
= new FileInputStream(groupFile
)) {
118 return DecryptedGroup
.ADAPTER
.decode(stream
);
119 } catch (IOException ignored
) {
124 private static File
getGroupV2FileLegacy(final GroupId groupId
, final File groupCachePath
) {
125 return new File(groupCachePath
, Hex
.toStringCondensed(groupId
.serialize()));
128 private static File
getGroupV2File(final GroupId groupId
, final File groupCachePath
) {
129 return new File(groupCachePath
, groupId
.toBase64().replace("/", "_"));
132 public record Storage(@JsonDeserialize(using
= GroupsDeserializer
.class) List
<Record
> groups
) {
134 public record GroupV1(
139 int messageExpirationTime
,
142 @JsonDeserialize(using
= MembersDeserializer
.class) List
<Member
> members
145 public record Member(Long recipientId
, String uuid
, String number
) {}
147 public record JsonRecipientAddress(String uuid
, String number
) {}
149 private static class MembersDeserializer
extends JsonDeserializer
<List
<Member
>> {
152 public List
<Member
> deserialize(
153 JsonParser jsonParser
, DeserializationContext deserializationContext
154 ) throws IOException
{
155 var addresses
= new ArrayList
<Member
>();
156 JsonNode node
= jsonParser
.getCodec().readTree(jsonParser
);
159 addresses
.add(new Member(null, null, n
.textValue()));
160 } else if (n
.isNumber()) {
161 addresses
.add(new Member(n
.numberValue().longValue(), null, null));
163 var address
= jsonParser
.getCodec().treeToValue(n
, JsonRecipientAddress
.class);
164 addresses
.add(new Member(null, address
.uuid
, address
.number
));
173 public record GroupV2(
176 String distributionId
,
177 @JsonInclude(JsonInclude
.Include
.NON_DEFAULT
) boolean blocked
,
178 @JsonInclude(JsonInclude
.Include
.NON_DEFAULT
) boolean permissionDenied
182 private static class GroupsDeserializer
extends JsonDeserializer
<List
<Object
>> {
185 public List
<Object
> deserialize(
186 JsonParser jsonParser
, DeserializationContext deserializationContext
187 ) throws IOException
{
188 var groups
= new ArrayList
<>();
189 JsonNode node
= jsonParser
.getCodec().readTree(jsonParser
);
192 if (n
.hasNonNull("masterKey")) {
194 g
= jsonParser
.getCodec().treeToValue(n
, Storage
.GroupV2
.class);
196 g
= jsonParser
.getCodec().treeToValue(n
, Storage
.GroupV1
.class);