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