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