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