]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/syncStorage/ContactRecordProcessor.java
d4841c7a1a849fe70fda0bc1db15375f9ee3942a
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / syncStorage / ContactRecordProcessor.java
1 package org.asamk.signal.manager.syncStorage;
2
3 import org.asamk.signal.manager.api.Contact;
4 import org.asamk.signal.manager.api.Profile;
5 import org.asamk.signal.manager.internal.JobExecutor;
6 import org.asamk.signal.manager.jobs.DownloadProfileJob;
7 import org.asamk.signal.manager.jobs.RefreshRecipientsJob;
8 import org.asamk.signal.manager.storage.SignalAccount;
9 import org.asamk.signal.manager.storage.recipients.RecipientAddress;
10 import org.asamk.signal.manager.util.KeyUtils;
11 import org.signal.libsignal.protocol.IdentityKey;
12 import org.signal.libsignal.protocol.InvalidKeyException;
13 import org.signal.libsignal.zkgroup.InvalidInputException;
14 import org.signal.libsignal.zkgroup.profiles.ProfileKey;
15 import org.slf4j.Logger;
16 import org.slf4j.LoggerFactory;
17 import org.whispersystems.signalservice.api.push.ServiceId.ACI;
18 import org.whispersystems.signalservice.api.push.ServiceId.PNI;
19 import org.whispersystems.signalservice.api.storage.SignalContactRecord;
20 import org.whispersystems.signalservice.api.storage.StorageId;
21 import org.whispersystems.signalservice.internal.storage.protos.ContactRecord;
22 import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState;
23
24 import java.sql.Connection;
25 import java.sql.SQLException;
26 import java.util.Arrays;
27 import java.util.Objects;
28 import java.util.Optional;
29 import java.util.regex.Pattern;
30
31 import okio.ByteString;
32
33 import static org.asamk.signal.manager.util.Utils.firstNonEmpty;
34 import static org.asamk.signal.manager.util.Utils.nullIfEmpty;
35
36 public class ContactRecordProcessor extends DefaultStorageRecordProcessor<SignalContactRecord> {
37
38 private static final Logger logger = LoggerFactory.getLogger(ContactRecordProcessor.class);
39
40 private static final Pattern E164_PATTERN = Pattern.compile("^\\+[1-9]\\d{0,18}$");
41
42 private final ACI selfAci;
43 private final PNI selfPni;
44 private final String selfNumber;
45 private final SignalAccount account;
46 private final Connection connection;
47 private final JobExecutor jobExecutor;
48
49 public ContactRecordProcessor(SignalAccount account, Connection connection, final JobExecutor jobExecutor) {
50 this.account = account;
51 this.connection = connection;
52 this.jobExecutor = jobExecutor;
53 this.selfAci = account.getAci();
54 this.selfPni = account.getPni();
55 this.selfNumber = account.getNumber();
56 }
57
58 /**
59 * Error cases:
60 * - You can't have a contact record without an ACI or PNI.
61 * - You can't have a contact record for yourself. That should be an account record.
62 */
63 @Override
64 protected boolean isInvalid(SignalContactRecord remoteRecord) {
65 final var remote = remoteRecord.getProto();
66 final var aci = ACI.parseOrNull(remote.aci);
67 final var pni = PNI.parseOrNull(remote.pni);
68 final var e164 = nullIfEmpty(remote.e164);
69 boolean hasAci = aci != null && aci.isValid();
70 boolean hasPni = pni != null && pni.isValid();
71
72 if (!hasAci && !hasPni) {
73 logger.debug("Found a ContactRecord with neither an ACI nor a PNI -- marking as invalid.");
74 return true;
75 } else if (selfAci != null && selfAci.equals(aci) || (
76 selfPni != null && selfPni.equals(pni)
77 ) || (selfNumber != null && selfNumber.equals(e164))) {
78 logger.debug("Found a ContactRecord for ourselves -- marking as invalid.");
79 return true;
80 } else if (e164 != null && !isValidE164(e164)) {
81 logger.debug("Found a record with an invalid E164 ({}). Marking as invalid.", e164);
82 return true;
83 } else {
84 return false;
85 }
86 }
87
88 @Override
89 protected Optional<SignalContactRecord> getMatching(SignalContactRecord remote) throws SQLException {
90 final var address = getRecipientAddress(remote.getProto());
91 final var recipientId = account.getRecipientStore().resolveRecipient(connection, address);
92 final var recipient = account.getRecipientStore().getRecipient(connection, recipientId);
93
94 final var identifier = recipient.getAddress().getIdentifier();
95 final var identity = account.getIdentityKeyStore().getIdentityInfo(connection, identifier);
96 final var storageId = account.getRecipientStore().getStorageId(connection, recipientId);
97
98 return Optional.of(new SignalContactRecord(storageId,
99 StorageSyncModels.localToRemoteRecord(recipient, identity)));
100 }
101
102 @Override
103 protected SignalContactRecord merge(SignalContactRecord remoteRecord, SignalContactRecord localRecord) {
104 final var remote = remoteRecord.getProto();
105 final var local = localRecord.getProto();
106
107 String profileGivenName;
108 String profileFamilyName;
109 if (!remote.givenName.isEmpty() || !remote.familyName.isEmpty()) {
110 profileGivenName = remote.givenName;
111 profileFamilyName = remote.familyName;
112 } else {
113 profileGivenName = local.givenName;
114 profileFamilyName = local.familyName;
115 }
116
117 IdentityState identityState;
118 ByteString identityKey;
119 if (remote.identityKey.size() > 0 && (
120 !account.isPrimaryDevice()
121 || remote.identityState != local.identityState
122 || local.identityKey.size() == 0
123
124 )) {
125 identityState = remote.identityState;
126 identityKey = remote.identityKey;
127 } else {
128 identityState = local.identityState;
129 identityKey = local.identityKey.size() > 0 ? local.identityKey : ByteString.EMPTY;
130 }
131
132 if (!local.aci.isEmpty()
133 && local.identityKey.size() > 0
134 && remote.identityKey.size() > 0
135 && !local.identityKey.equals(remote.identityKey)) {
136 logger.debug("The local and remote identity keys do not match for {}. Enqueueing a profile fetch.",
137 local.aci);
138 final var address = getRecipientAddress(local);
139 jobExecutor.enqueueJob(new DownloadProfileJob(address));
140 }
141
142 String pni;
143 String e164;
144 if (account.isPrimaryDevice()) {
145 final var e164sMatchButPnisDont = !local.e164.isEmpty()
146 && local.e164.equals(remote.e164)
147 && !local.pni.isEmpty()
148 && !remote.pni.isEmpty()
149 && !local.pni.equals(remote.pni);
150
151 final var pnisMatchButE164sDont = !local.pni.isEmpty()
152 && local.pni.equals(remote.pni)
153 && !local.e164.isEmpty()
154 && !remote.e164.isEmpty()
155 && !local.e164.equals(remote.e164);
156
157 if (e164sMatchButPnisDont || pnisMatchButE164sDont) {
158 if (e164sMatchButPnisDont) {
159 logger.debug("Matching E164s, but the PNIs differ! Trusting our local pair.");
160 } else if (pnisMatchButE164sDont) {
161 logger.debug("Matching PNIs, but the E164s differ! Trusting our local pair.");
162 }
163 jobExecutor.enqueueJob(new RefreshRecipientsJob());
164 pni = local.pni;
165 e164 = local.e164;
166 } else {
167 pni = firstNonEmpty(remote.pni, local.pni);
168 e164 = firstNonEmpty(remote.e164, local.e164);
169 }
170 } else {
171 pni = firstNonEmpty(remote.pni, local.pni);
172 e164 = firstNonEmpty(remote.e164, local.e164);
173 }
174
175 final var mergedBuilder = SignalContactRecord.Companion.newBuilder(remote.unknownFields().toByteArray())
176 .aci(local.aci.isEmpty() ? remote.aci : local.aci)
177 .e164(e164)
178 .pni(pni)
179 .givenName(profileGivenName)
180 .familyName(profileFamilyName)
181 .systemGivenName(account.isPrimaryDevice() ? local.systemGivenName : remote.systemGivenName)
182 .systemFamilyName(account.isPrimaryDevice() ? local.systemFamilyName : remote.systemFamilyName)
183 .systemNickname(remote.systemNickname)
184 .profileKey(firstNonEmpty(remote.profileKey, local.profileKey))
185 .username(firstNonEmpty(remote.username, local.username))
186 .identityState(identityState)
187 .identityKey(identityKey)
188 .blocked(remote.blocked)
189 .whitelisted(remote.whitelisted)
190 .archived(remote.archived)
191 .markedUnread(remote.markedUnread)
192 .mutedUntilTimestamp(remote.mutedUntilTimestamp)
193 .hideStory(remote.hideStory)
194 .unregisteredAtTimestamp(remote.unregisteredAtTimestamp)
195 .hidden(remote.hidden)
196 .pniSignatureVerified(remote.pniSignatureVerified || local.pniSignatureVerified)
197 .nickname(remote.nickname)
198 .note(remote.note)
199 .avatarColor(remote.avatarColor);
200 final var merged = mergedBuilder.build();
201
202 final var matchesRemote = doProtosMatch(merged, remote);
203 if (matchesRemote) {
204 return remoteRecord;
205 }
206
207 final var matchesLocal = doProtosMatch(merged, local);
208 if (matchesLocal) {
209 return localRecord;
210 }
211
212 return new SignalContactRecord(StorageId.forContact(KeyUtils.createRawStorageId()), mergedBuilder.build());
213 }
214
215 @Override
216 protected void insertLocal(SignalContactRecord record) throws SQLException {
217 StorageRecordUpdate<SignalContactRecord> update = new StorageRecordUpdate<>(null, record);
218 updateLocal(update);
219 }
220
221 @Override
222 protected void updateLocal(StorageRecordUpdate<SignalContactRecord> update) throws SQLException {
223 final var contactRecord = update.newRecord();
224 final var contactProto = contactRecord.getProto();
225 final var address = getRecipientAddress(contactProto);
226 final var recipientId = account.getRecipientStore().resolveRecipientTrusted(connection, address);
227 final var recipient = account.getRecipientStore().getRecipient(connection, recipientId);
228
229 final var contact = recipient.getContact();
230 final var blocked = contact != null && contact.isBlocked();
231 final var profileShared = contact != null && contact.isProfileSharingEnabled();
232 final var archived = contact != null && contact.isArchived();
233 final var hidden = contact != null && contact.isHidden();
234 final var hideStory = contact != null && contact.hideStory();
235 final var muteUntil = contact == null ? 0 : contact.muteUntil();
236 final var unregisteredTimestamp = contact == null || contact.unregisteredTimestamp() == null
237 ? 0
238 : contact.unregisteredTimestamp();
239 final var contactGivenName = contact == null ? null : contact.givenName();
240 final var contactFamilyName = contact == null ? null : contact.familyName();
241 final var contactNickName = contact == null ? null : contact.nickName();
242 final var contactNickGivenName = contact == null ? null : contact.nickNameGivenName();
243 final var contactNickFamilyName = contact == null ? null : contact.nickNameFamilyName();
244 final var contactNote = contact == null ? null : contact.note();
245 if (blocked != contactProto.blocked
246 || profileShared != contactProto.whitelisted
247 || archived != contactProto.archived
248 || hidden != contactProto.hidden
249 || hideStory != contactProto.hideStory
250 || muteUntil != contactProto.mutedUntilTimestamp
251 || unregisteredTimestamp != contactProto.unregisteredAtTimestamp
252 || !Objects.equals(nullIfEmpty(contactProto.systemGivenName), contactGivenName)
253 || !Objects.equals(nullIfEmpty(contactProto.systemFamilyName), contactFamilyName)
254 || !Objects.equals(nullIfEmpty(contactProto.systemNickname), contactNickName)
255 || !Objects.equals(nullIfEmpty(contactProto.nickname == null ? "" : contactProto.nickname.given),
256 contactNickGivenName)
257 || !Objects.equals(nullIfEmpty(contactProto.nickname == null ? "" : contactProto.nickname.family),
258 contactNickFamilyName)
259 || !Objects.equals(nullIfEmpty(contactProto.note), contactNote)) {
260 logger.debug("Storing new or updated contact {}", recipientId);
261 final var contactBuilder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact);
262 final var newContact = contactBuilder.withIsBlocked(contactProto.blocked)
263 .withIsProfileSharingEnabled(contactProto.whitelisted)
264 .withIsArchived(contactProto.archived)
265 .withIsHidden(contactProto.hidden)
266 .withMuteUntil(contactProto.mutedUntilTimestamp)
267 .withHideStory(contactProto.hideStory)
268 .withGivenName(nullIfEmpty(contactProto.systemGivenName))
269 .withFamilyName(nullIfEmpty(contactProto.systemFamilyName))
270 .withNickName(nullIfEmpty(contactProto.systemNickname))
271 .withNickNameGivenName(nullIfEmpty(contactProto.nickname == null
272 ? null
273 : contactProto.nickname.given))
274 .withNickNameFamilyName(nullIfEmpty(contactProto.nickname == null
275 ? null
276 : contactProto.nickname.family))
277 .withNote(nullIfEmpty(contactProto.note))
278 .withUnregisteredTimestamp(contactProto.unregisteredAtTimestamp == 0
279 ? null
280 : contactProto.unregisteredAtTimestamp);
281 account.getRecipientStore().storeContact(connection, recipientId, newContact.build());
282 }
283
284 final var profile = recipient.getProfile();
285 final var profileGivenName = profile == null ? null : profile.getGivenName();
286 final var profileFamilyName = profile == null ? null : profile.getFamilyName();
287 if (!Objects.equals(nullIfEmpty(contactProto.givenName), profileGivenName) || !Objects.equals(nullIfEmpty(
288 contactProto.familyName), profileFamilyName)) {
289 final var profileBuilder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
290 final var newProfile = profileBuilder.withGivenName(nullIfEmpty(contactProto.givenName))
291 .withFamilyName(nullIfEmpty(contactProto.familyName))
292 .build();
293 account.getRecipientStore().storeProfile(connection, recipientId, newProfile);
294 }
295 if (contactProto.profileKey.size() > 0) {
296 try {
297 logger.trace("Storing profile key {}", recipientId);
298 final var profileKey = new ProfileKey(contactProto.profileKey.toByteArray());
299 account.getRecipientStore().storeProfileKey(connection, recipientId, profileKey);
300 } catch (InvalidInputException e) {
301 logger.warn("Received invalid contact profile key from storage");
302 }
303 }
304 if (contactProto.identityKey.size() > 0 && address.aci().isPresent()) {
305 try {
306 logger.trace("Storing identity key {}", recipientId);
307 final var identityKey = new IdentityKey(contactProto.identityKey.toByteArray());
308 account.getIdentityKeyStore().saveIdentity(connection, address.aci().get(), identityKey);
309
310 final var trustLevel = StorageSyncModels.remoteToLocal(contactProto.identityState);
311 if (trustLevel != null) {
312 account.getIdentityKeyStore()
313 .setIdentityTrustLevel(connection, address.aci().get(), identityKey, trustLevel);
314 }
315 } catch (InvalidKeyException e) {
316 logger.warn("Received invalid contact identity key from storage");
317 }
318 }
319 account.getRecipientStore()
320 .storeStorageRecord(connection, recipientId, contactRecord.getId(), contactProto.encode());
321 }
322
323 private static RecipientAddress getRecipientAddress(final ContactRecord contactRecord) {
324 return new RecipientAddress(ACI.parseOrNull(contactRecord.aci),
325 PNI.parseOrNull(contactRecord.pni),
326 nullIfEmpty(contactRecord.e164),
327 nullIfEmpty(contactRecord.username));
328 }
329
330 @Override
331 public int compare(SignalContactRecord lhsRecord, SignalContactRecord rhsRecord) {
332 final var lhs = lhsRecord.getProto();
333 final var rhs = rhsRecord.getProto();
334 if ((!lhs.aci.isEmpty() && Objects.equals(lhs.aci, rhs.aci)) || (
335 !lhs.e164.isEmpty() && Objects.equals(lhs.e164, rhs.e164)
336 ) || (!lhs.pni.isEmpty() && Objects.equals(lhs.pni, rhs.pni))) {
337 return 0;
338 } else {
339 return 1;
340 }
341 }
342
343 private static boolean isValidE164(String value) {
344 return E164_PATTERN.matcher(value).matches();
345 }
346
347 private static boolean doProtosMatch(ContactRecord merged, ContactRecord other) {
348 return Arrays.equals(merged.encode(), other.encode());
349 }
350 }