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