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