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