]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
Store profile phone number sharing mode and discoverable state
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / helper / ProfileHelper.java
1 package org.asamk.signal.manager.helper;
2
3 import org.asamk.signal.manager.api.GroupNotFoundException;
4 import org.asamk.signal.manager.api.NotAGroupMemberException;
5 import org.asamk.signal.manager.api.PhoneNumberSharingMode;
6 import org.asamk.signal.manager.api.Profile;
7 import org.asamk.signal.manager.config.ServiceConfig;
8 import org.asamk.signal.manager.internal.SignalDependencies;
9 import org.asamk.signal.manager.jobs.SyncStorageJob;
10 import org.asamk.signal.manager.storage.SignalAccount;
11 import org.asamk.signal.manager.storage.groups.GroupInfoV2;
12 import org.asamk.signal.manager.storage.recipients.RecipientAddress;
13 import org.asamk.signal.manager.storage.recipients.RecipientId;
14 import org.asamk.signal.manager.util.IOUtils;
15 import org.asamk.signal.manager.util.KeyUtils;
16 import org.asamk.signal.manager.util.PaymentUtils;
17 import org.asamk.signal.manager.util.ProfileUtils;
18 import org.asamk.signal.manager.util.Utils;
19 import org.signal.libsignal.protocol.IdentityKey;
20 import org.signal.libsignal.protocol.InvalidKeyException;
21 import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
22 import org.signal.libsignal.zkgroup.profiles.ProfileKey;
23 import org.slf4j.Logger;
24 import org.slf4j.LoggerFactory;
25 import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
26 import org.whispersystems.signalservice.api.profiles.AvatarUploadParams;
27 import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
28 import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
29 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
30 import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
31 import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
32 import org.whispersystems.signalservice.api.services.ProfileService;
33 import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil;
34
35 import java.io.IOException;
36 import java.io.OutputStream;
37 import java.nio.file.Files;
38 import java.util.Base64;
39 import java.util.Collection;
40 import java.util.List;
41 import java.util.Locale;
42 import java.util.Objects;
43 import java.util.Optional;
44 import java.util.Set;
45
46 import io.reactivex.rxjava3.core.Flowable;
47 import io.reactivex.rxjava3.core.Maybe;
48 import io.reactivex.rxjava3.core.Single;
49
50 public final class ProfileHelper {
51
52 private static final Logger logger = LoggerFactory.getLogger(ProfileHelper.class);
53
54 private final SignalAccount account;
55 private final SignalDependencies dependencies;
56 private final Context context;
57
58 public ProfileHelper(final Context context) {
59 this.account = context.getAccount();
60 this.dependencies = context.getDependencies();
61 this.context = context;
62 }
63
64 public void rotateProfileKey() throws IOException {
65 // refresh our profile, before creating a new profile key
66 getSelfProfile();
67 var profileKey = KeyUtils.createProfileKey();
68 account.setProfileKey(profileKey);
69 context.getAccountHelper().updateAccountAttributes();
70 setProfile(true, true, null, null, null, null, null, null);
71 account.getRecipientStore().rotateSelfStorageId();
72 context.getJobExecutor().enqueueJob(new SyncStorageJob());
73
74 final var recipientIds = account.getRecipientStore().getRecipientIdsWithEnabledProfileSharing();
75 for (final var recipientId : recipientIds) {
76 context.getSendHelper().sendProfileKey(recipientId);
77 }
78
79 final var selfRecipientId = account.getSelfRecipientId();
80 final var activeGroupIds = account.getGroupStore()
81 .getGroups()
82 .stream()
83 .filter(g -> g instanceof GroupInfoV2 && g.isMember(selfRecipientId) && g.isProfileSharingEnabled())
84 .map(g -> (GroupInfoV2) g)
85 .map(GroupInfoV2::getGroupId)
86 .toList();
87 for (final var groupId : activeGroupIds) {
88 try {
89 context.getGroupHelper().updateGroupProfileKey(groupId);
90 } catch (GroupNotFoundException | NotAGroupMemberException | IOException e) {
91 logger.warn("Failed to update group profile key: {}", e.getMessage());
92 }
93 }
94 }
95
96 public Profile getRecipientProfile(RecipientId recipientId) {
97 return getRecipientProfile(recipientId, false);
98 }
99
100 public List<Profile> getRecipientProfiles(Collection<RecipientId> recipientIds) {
101 return getRecipientProfiles(recipientIds, false);
102 }
103
104 public void refreshRecipientProfile(RecipientId recipientId) {
105 getRecipientProfile(recipientId, true);
106 }
107
108 public void refreshRecipientProfiles(Collection<RecipientId> recipientIds) {
109 getRecipientProfiles(recipientIds, true);
110 }
111
112 public List<ExpiringProfileKeyCredential> getExpiringProfileKeyCredential(List<RecipientId> recipientIds) {
113 final var profileFetches = Flowable.fromIterable(recipientIds)
114 .filter(recipientId -> !ExpiringProfileCredentialUtil.isValid(account.getProfileStore()
115 .getExpiringProfileKeyCredential(recipientId)))
116 .map(recipientId -> retrieveProfile(recipientId,
117 SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL).onErrorComplete());
118 Maybe.merge(profileFetches, 10).blockingSubscribe();
119
120 return recipientIds.stream().map(r -> account.getProfileStore().getExpiringProfileKeyCredential(r)).toList();
121 }
122
123 public ExpiringProfileKeyCredential getExpiringProfileKeyCredential(RecipientId recipientId) {
124 var profileKeyCredential = account.getProfileStore().getExpiringProfileKeyCredential(recipientId);
125 if (ExpiringProfileCredentialUtil.isValid(profileKeyCredential)) {
126 return profileKeyCredential;
127 }
128
129 try {
130 blockingGetProfile(retrieveProfile(recipientId, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL));
131 } catch (IOException e) {
132 logger.warn("Failed to retrieve profile key credential, ignoring: {}", e.getMessage());
133 return null;
134 }
135
136 return account.getProfileStore().getExpiringProfileKeyCredential(recipientId);
137 }
138
139 /**
140 * @param givenName if null, the previous givenName will be kept
141 * @param familyName if null, the previous familyName will be kept
142 * @param about if null, the previous about text will be kept
143 * @param aboutEmoji if null, the previous about emoji will be kept
144 * @param avatar if avatar is null the image from the local avatar store is used (if present),
145 */
146 public void setProfile(
147 String givenName,
148 final String familyName,
149 String about,
150 String aboutEmoji,
151 Optional<String> avatar,
152 byte[] mobileCoinAddress
153 ) throws IOException {
154 setProfile(true, false, givenName, familyName, about, aboutEmoji, avatar, mobileCoinAddress);
155 }
156
157 public void setProfile(
158 boolean uploadProfile,
159 boolean forceUploadAvatar,
160 String givenName,
161 final String familyName,
162 String about,
163 String aboutEmoji,
164 Optional<String> avatar,
165 byte[] mobileCoinAddress
166 ) throws IOException {
167 var profile = getSelfProfile();
168 var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
169 if (givenName != null) {
170 builder.withGivenName(givenName);
171 }
172 if (familyName != null) {
173 builder.withFamilyName(familyName);
174 }
175 if (about != null) {
176 builder.withAbout(about);
177 }
178 if (aboutEmoji != null) {
179 builder.withAboutEmoji(aboutEmoji);
180 }
181 if (mobileCoinAddress != null) {
182 builder.withMobileCoinAddress(mobileCoinAddress);
183 }
184 var newProfile = builder.build();
185
186 if (uploadProfile) {
187 final var streamDetails = avatar != null && avatar.isPresent()
188 ? Utils.createStreamDetails(avatar.get())
189 .first()
190 : forceUploadAvatar && avatar == null ? context.getAvatarStore()
191 .retrieveProfileAvatar(account.getSelfRecipientAddress()) : null;
192 try (streamDetails) {
193 final var avatarUploadParams = streamDetails != null
194 ? AvatarUploadParams.forAvatar(streamDetails)
195 : avatar == null ? AvatarUploadParams.unchanged(true) : AvatarUploadParams.unchanged(false);
196 final var paymentsAddress = Optional.ofNullable(newProfile.getMobileCoinAddress())
197 .map(address -> PaymentUtils.signPaymentsAddress(address,
198 account.getAciIdentityKeyPair().getPrivateKey()));
199 logger.debug("Uploading new profile");
200 final var avatarPath = dependencies.getAccountManager()
201 .setVersionedProfile(account.getAci(),
202 account.getProfileKey(),
203 newProfile.getInternalServiceName(),
204 newProfile.getAbout() == null ? "" : newProfile.getAbout(),
205 newProfile.getAboutEmoji() == null ? "" : newProfile.getAboutEmoji(),
206 paymentsAddress,
207 avatarUploadParams,
208 List.of(/* TODO implement support for badges */),
209 account.getConfigurationStore().getPhoneNumberSharingMode()
210 == PhoneNumberSharingMode.EVERYBODY);
211 if (!avatarUploadParams.keepTheSame) {
212 builder.withAvatarUrlPath(avatarPath.orElse(null));
213 }
214 newProfile = builder.build();
215 }
216 }
217
218 if (avatar != null) {
219 if (avatar.isPresent()) {
220 try (final var streamDetails = Utils.createStreamDetails(avatar.get()).first()) {
221 context.getAvatarStore()
222 .storeProfileAvatar(account.getSelfRecipientAddress(),
223 outputStream -> IOUtils.copyStream(streamDetails.getStream(), outputStream));
224 }
225 } else {
226 context.getAvatarStore().deleteProfileAvatar(account.getSelfRecipientAddress());
227 }
228 }
229 account.getProfileStore().storeProfile(account.getSelfRecipientId(), newProfile);
230 }
231
232 public Profile getSelfProfile() {
233 return getRecipientProfile(account.getSelfRecipientId());
234 }
235
236 private List<Profile> getRecipientProfiles(Collection<RecipientId> recipientIds, boolean force) {
237 final var profileStore = account.getProfileStore();
238 final var profileFetches = Flowable.fromIterable(recipientIds)
239 .filter(recipientId -> force || isProfileRefreshRequired(profileStore.getProfile(recipientId)))
240 .map(recipientId -> retrieveProfile(recipientId,
241 SignalServiceProfile.RequestType.PROFILE).onErrorComplete());
242 Maybe.merge(profileFetches, 10).blockingSubscribe();
243
244 return recipientIds.stream().map(profileStore::getProfile).toList();
245 }
246
247 private Profile getRecipientProfile(RecipientId recipientId, boolean force) {
248 var profile = account.getProfileStore().getProfile(recipientId);
249
250 if (!force && !isProfileRefreshRequired(profile)) {
251 return profile;
252 }
253
254 try {
255 blockingGetProfile(retrieveProfile(recipientId, SignalServiceProfile.RequestType.PROFILE));
256 } catch (IOException e) {
257 logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
258 }
259
260 return account.getProfileStore().getProfile(recipientId);
261 }
262
263 private boolean isProfileRefreshRequired(final Profile profile) {
264 if (profile == null) {
265 return true;
266 }
267 // Profiles are cached for 6h before retrieving them again, unless forced
268 final var now = System.currentTimeMillis();
269 return now - profile.getLastUpdateTimestamp() >= 6 * 60 * 60 * 1000;
270 }
271
272 private Profile decryptProfileAndDownloadAvatar(
273 final RecipientId recipientId, final ProfileKey profileKey, final SignalServiceProfile encryptedProfile
274 ) {
275 final var avatarPath = encryptedProfile.getAvatar();
276 downloadProfileAvatar(recipientId, avatarPath, profileKey);
277
278 return ProfileUtils.decryptProfile(profileKey, encryptedProfile);
279 }
280
281 public void downloadProfileAvatar(
282 final RecipientId recipientId, final String avatarPath, final ProfileKey profileKey
283 ) {
284 var profile = account.getProfileStore().getProfile(recipientId);
285 if (profile == null || !Objects.equals(avatarPath, profile.getAvatarUrlPath())) {
286 logger.trace("Downloading profile avatar for {}", recipientId);
287 downloadProfileAvatar(account.getRecipientAddressResolver().resolveRecipientAddress(recipientId),
288 avatarPath,
289 profileKey);
290 var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
291 account.getProfileStore().storeProfile(recipientId, builder.withAvatarUrlPath(avatarPath).build());
292 }
293 }
294
295 private ProfileAndCredential blockingGetProfile(Single<ProfileAndCredential> profile) throws IOException {
296 try {
297 return profile.blockingGet();
298 } catch (RuntimeException e) {
299 if (e.getCause() instanceof PushNetworkException) {
300 throw (PushNetworkException) e.getCause();
301 } else if (e.getCause() instanceof NotFoundException) {
302 throw (NotFoundException) e.getCause();
303 } else {
304 throw new IOException(e);
305 }
306 }
307 }
308
309 private Single<ProfileAndCredential> retrieveProfile(
310 RecipientId recipientId, SignalServiceProfile.RequestType requestType
311 ) {
312 var unidentifiedAccess = getUnidentifiedAccess(recipientId);
313 var profileKey = Optional.ofNullable(account.getProfileStore().getProfileKey(recipientId));
314
315 logger.trace("Retrieving profile for {} {}",
316 recipientId,
317 profileKey.isPresent() ? "with profile key" : "without profile key");
318 final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
319 return retrieveProfile(address, profileKey, unidentifiedAccess, requestType).doOnSuccess(p -> {
320 logger.trace("Got new profile for {}", recipientId);
321 final var encryptedProfile = p.getProfile();
322
323 if (requestType == SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL
324 || !ExpiringProfileCredentialUtil.isValid(account.getProfileStore()
325 .getExpiringProfileKeyCredential(recipientId))) {
326 logger.trace("Storing profile credential");
327 final var profileKeyCredential = p.getExpiringProfileKeyCredential().orElse(null);
328 account.getProfileStore().storeExpiringProfileKeyCredential(recipientId, profileKeyCredential);
329 }
330
331 final var profile = account.getProfileStore().getProfile(recipientId);
332
333 if (recipientId.equals(account.getSelfRecipientId())) {
334 final var isUnrestricted = encryptedProfile.isUnrestrictedUnidentifiedAccess();
335 if (account.isUnrestrictedUnidentifiedAccess() != isUnrestricted) {
336 account.setUnrestrictedUnidentifiedAccess(isUnrestricted);
337 }
338 }
339
340 Profile newProfile = null;
341 if (profileKey.isPresent()) {
342 logger.trace("Decrypting profile");
343 newProfile = decryptProfileAndDownloadAvatar(recipientId, profileKey.get(), encryptedProfile);
344 }
345
346 if (newProfile == null) {
347 newProfile = (
348 profile == null ? Profile.newBuilder() : Profile.newBuilder(profile)
349 ).withLastUpdateTimestamp(System.currentTimeMillis())
350 .withUnidentifiedAccessMode(ProfileUtils.getUnidentifiedAccessMode(encryptedProfile, null))
351 .withCapabilities(ProfileUtils.getCapabilities(encryptedProfile))
352 .build();
353 }
354
355 try {
356 logger.trace("Storing identity");
357 final var identityKey = new IdentityKey(Base64.getDecoder().decode(encryptedProfile.getIdentityKey()));
358 account.getIdentityKeyStore().saveIdentity(p.getProfile().getServiceId(), identityKey);
359 } catch (InvalidKeyException ignored) {
360 logger.warn("Got invalid identity key in profile for {}",
361 context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier());
362 }
363
364 logger.trace("Storing profile");
365 account.getProfileStore().storeProfile(recipientId, newProfile);
366 account.getRecipientStore().markRegistered(recipientId, true);
367
368 logger.trace("Done handling retrieved profile");
369 }).doOnError(e -> {
370 logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
371 final var profile = account.getProfileStore().getProfile(recipientId);
372 final var newProfile = (
373 profile == null ? Profile.newBuilder() : Profile.newBuilder(profile)
374 ).withLastUpdateTimestamp(System.currentTimeMillis())
375 .withUnidentifiedAccessMode(Profile.UnidentifiedAccessMode.UNKNOWN)
376 .withCapabilities(Set.of())
377 .build();
378 if (e instanceof NotFoundException) {
379 logger.debug("Marking recipient {} as unregistered after 404 profile fetch.", recipientId);
380 account.getRecipientStore().markRegistered(recipientId, false);
381 }
382
383 account.getProfileStore().storeProfile(recipientId, newProfile);
384 });
385 }
386
387 private Single<ProfileAndCredential> retrieveProfile(
388 SignalServiceAddress address,
389 Optional<ProfileKey> profileKey,
390 Optional<UnidentifiedAccess> unidentifiedAccess,
391 SignalServiceProfile.RequestType requestType
392 ) {
393 final var profileService = dependencies.getProfileService();
394 final var locale = Utils.getDefaultLocale(Locale.US);
395
396 return profileService.getProfile(address, profileKey, unidentifiedAccess, requestType, locale).map(pair -> {
397 var processor = new ProfileService.ProfileResponseProcessor(pair);
398 if (processor.hasResult()) {
399 return processor.getResult();
400 } else if (processor.notFound()) {
401 throw new NotFoundException("Profile not found");
402 } else {
403 throw pair.getExecutionError()
404 .or(pair::getApplicationError)
405 .orElseThrow(() -> new IOException("Unknown error while retrieving profile"));
406 }
407 });
408 }
409
410 private void downloadProfileAvatar(
411 RecipientAddress address, String avatarPath, ProfileKey profileKey
412 ) {
413 if (avatarPath == null) {
414 try {
415 context.getAvatarStore().deleteProfileAvatar(address);
416 } catch (IOException e) {
417 logger.warn("Failed to delete local profile avatar, ignoring: {}", e.getMessage());
418 }
419 return;
420 }
421
422 try {
423 context.getAvatarStore()
424 .storeProfileAvatar(address,
425 outputStream -> retrieveProfileAvatar(avatarPath, profileKey, outputStream));
426 } catch (Throwable e) {
427 logger.warn("Failed to download profile avatar, ignoring: {}", e.getMessage());
428 }
429 }
430
431 private void retrieveProfileAvatar(
432 String avatarPath, ProfileKey profileKey, OutputStream outputStream
433 ) throws IOException {
434 var tmpFile = IOUtils.createTempFile();
435 try (var input = dependencies.getMessageReceiver()
436 .retrieveProfileAvatar(avatarPath,
437 tmpFile,
438 profileKey,
439 ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) {
440 // Use larger buffer size to prevent AssertionError: Need: 12272 but only have: 8192 ...
441 IOUtils.copyStream(input, outputStream, (int) ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE);
442 } finally {
443 try {
444 Files.delete(tmpFile.toPath());
445 } catch (IOException e) {
446 logger.warn("Failed to delete received profile avatar temp file “{}”, ignoring: {}",
447 tmpFile,
448 e.getMessage());
449 }
450 }
451 }
452
453 private Optional<UnidentifiedAccess> getUnidentifiedAccess(RecipientId recipientId) {
454 var unidentifiedAccess = context.getUnidentifiedAccessHelper().getAccessFor(recipientId, true);
455
456 if (unidentifiedAccess.isPresent()) {
457 return unidentifiedAccess.get().getTargetUnidentifiedAccess();
458 }
459
460 return Optional.empty();
461 }
462 }