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