]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
Move saving out of synchronized block
[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.HashSet;
34 import java.util.List;
35 import java.util.Objects;
36 import java.util.Set;
37
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 ProfileKeyCredential getRecipientProfileKeyCredential(RecipientId recipientId) {
73 var profileKeyCredential = account.getProfileStore().getProfileKeyCredential(recipientId);
74 if (profileKeyCredential != null) {
75 return profileKeyCredential;
76 }
77
78 ProfileAndCredential profileAndCredential;
79 try {
80 profileAndCredential = retrieveProfileAndCredential(recipientId,
81 SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL);
82 } catch (IOException e) {
83 logger.warn("Failed to retrieve profile key credential, ignoring: {}", e.getMessage());
84 return null;
85 }
86
87 profileKeyCredential = profileAndCredential.getProfileKeyCredential().orNull();
88 account.getProfileStore().storeProfileKeyCredential(recipientId, profileKeyCredential);
89
90 var profileKey = account.getProfileStore().getProfileKey(recipientId);
91 if (profileKey != null) {
92 final var profile = decryptProfileAndDownloadAvatar(recipientId,
93 profileKey,
94 profileAndCredential.getProfile());
95 account.getProfileStore().storeProfile(recipientId, profile);
96 }
97
98 return profileKeyCredential;
99 }
100
101 /**
102 * @param givenName if null, the previous givenName will be kept
103 * @param familyName if null, the previous familyName will be kept
104 * @param about if null, the previous about text will be kept
105 * @param aboutEmoji if null, the previous about emoji will be kept
106 * @param avatar if avatar is null the image from the local avatar store is used (if present),
107 */
108 public void setProfile(
109 String givenName, final String familyName, String about, String aboutEmoji, Optional<File> avatar
110 ) throws IOException {
111 setProfile(true, givenName, familyName, about, aboutEmoji, avatar);
112 }
113
114 public void setProfile(
115 boolean uploadProfile,
116 String givenName,
117 final String familyName,
118 String about,
119 String aboutEmoji,
120 Optional<File> avatar
121 ) throws IOException {
122 var profile = getRecipientProfile(account.getSelfRecipientId());
123 var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
124 if (givenName != null) {
125 builder.withGivenName(givenName);
126 }
127 if (familyName != null) {
128 builder.withFamilyName(familyName);
129 }
130 if (about != null) {
131 builder.withAbout(about);
132 }
133 if (aboutEmoji != null) {
134 builder.withAboutEmoji(aboutEmoji);
135 }
136 var newProfile = builder.build();
137
138 if (uploadProfile) {
139 try (final var streamDetails = avatar == null
140 ? avatarStore.retrieveProfileAvatar(account.getSelfAddress())
141 : avatar.isPresent() ? Utils.createStreamDetailsFromFile(avatar.get()) : null) {
142 final var avatarPath = dependencies.getAccountManager()
143 .setVersionedProfile(account.getAci(),
144 account.getProfileKey(),
145 newProfile.getInternalServiceName(),
146 newProfile.getAbout() == null ? "" : newProfile.getAbout(),
147 newProfile.getAboutEmoji() == null ? "" : newProfile.getAboutEmoji(),
148 Optional.absent(),
149 streamDetails,
150 List.of(/* TODO */));
151 builder.withAvatarUrlPath(avatarPath.orNull());
152 newProfile = builder.build();
153 }
154 }
155
156 if (avatar != null) {
157 if (avatar.isPresent()) {
158 avatarStore.storeProfileAvatar(account.getSelfAddress(),
159 outputStream -> IOUtils.copyFileToStream(avatar.get(), outputStream));
160 } else {
161 avatarStore.deleteProfileAvatar(account.getSelfAddress());
162 }
163 }
164 account.getProfileStore().storeProfile(account.getSelfRecipientId(), newProfile);
165 }
166
167 private final Set<RecipientId> pendingProfileRequest = new HashSet<>();
168
169 private Profile getRecipientProfile(RecipientId recipientId, boolean force) {
170 var profile = account.getProfileStore().getProfile(recipientId);
171
172 var now = System.currentTimeMillis();
173 // Profiles are cached for 24h before retrieving them again, unless forced
174 if (!force && profile != null && now - profile.getLastUpdateTimestamp() < 6 * 60 * 60 * 1000) {
175 return profile;
176 }
177
178 synchronized (pendingProfileRequest) {
179 if (pendingProfileRequest.contains(recipientId)) {
180 return profile;
181 }
182 pendingProfileRequest.add(recipientId);
183 }
184 final SignalServiceProfile encryptedProfile;
185 try {
186 encryptedProfile = retrieveEncryptedProfile(recipientId);
187 } finally {
188 synchronized (pendingProfileRequest) {
189 pendingProfileRequest.remove(recipientId);
190 }
191 }
192
193 Profile newProfile = null;
194 if (encryptedProfile != null) {
195 var profileKey = account.getProfileStore().getProfileKey(recipientId);
196 if (profileKey != null) {
197 newProfile = decryptProfileAndDownloadAvatar(recipientId, profileKey, encryptedProfile);
198 if (newProfile == null) {
199 account.getProfileStore().storeProfileKey(recipientId, null);
200 }
201 }
202
203 if (newProfile == null) {
204 newProfile = (
205 profile == null ? Profile.newBuilder() : Profile.newBuilder(profile)
206 ).withLastUpdateTimestamp(System.currentTimeMillis())
207 .withUnidentifiedAccessMode(ProfileUtils.getUnidentifiedAccessMode(encryptedProfile, null))
208 .withCapabilities(ProfileUtils.getCapabilities(encryptedProfile))
209 .build();
210 }
211 }
212
213 if (newProfile == null) {
214 newProfile = (
215 profile == null ? Profile.newBuilder() : Profile.newBuilder(profile)
216 ).withLastUpdateTimestamp(now)
217 .withUnidentifiedAccessMode(Profile.UnidentifiedAccessMode.UNKNOWN)
218 .withCapabilities(Set.of())
219 .build();
220 }
221
222 account.getProfileStore().storeProfile(recipientId, newProfile);
223
224 return newProfile;
225 }
226
227 private SignalServiceProfile retrieveEncryptedProfile(RecipientId recipientId) {
228 try {
229 return retrieveProfileAndCredential(recipientId, SignalServiceProfile.RequestType.PROFILE).getProfile();
230 } catch (IOException e) {
231 logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
232 return null;
233 }
234 }
235
236 private SignalServiceProfile retrieveProfileSync(String username) throws IOException {
237 final var locale = Utils.getDefaultLocale();
238 return dependencies.getMessageReceiver().retrieveProfileByUsername(username, Optional.absent(), locale);
239 }
240
241 private ProfileAndCredential retrieveProfileAndCredential(
242 final RecipientId recipientId, final SignalServiceProfile.RequestType requestType
243 ) throws IOException {
244 final var profileAndCredential = retrieveProfileSync(recipientId, requestType);
245 final var profile = profileAndCredential.getProfile();
246
247 try {
248 var newIdentity = account.getIdentityKeyStore()
249 .saveIdentity(recipientId,
250 new IdentityKey(Base64.getDecoder().decode(profile.getIdentityKey())),
251 new Date());
252
253 if (newIdentity) {
254 account.getSessionStore().archiveSessions(recipientId);
255 account.getSenderKeyStore().deleteSharedWith(recipientId);
256 }
257 } catch (InvalidKeyException ignored) {
258 logger.warn("Got invalid identity key in profile for {}",
259 addressResolver.resolveSignalServiceAddress(recipientId).getIdentifier());
260 }
261 return profileAndCredential;
262 }
263
264 private Profile decryptProfileAndDownloadAvatar(
265 final RecipientId recipientId, final ProfileKey profileKey, final SignalServiceProfile encryptedProfile
266 ) {
267 final var avatarPath = encryptedProfile.getAvatar();
268 downloadProfileAvatar(recipientId, avatarPath, profileKey);
269
270 return ProfileUtils.decryptProfile(profileKey, encryptedProfile);
271 }
272
273 public void downloadProfileAvatar(
274 final RecipientId recipientId, final String avatarPath, final ProfileKey profileKey
275 ) {
276 var profile = account.getProfileStore().getProfile(recipientId);
277 if (profile == null || !Objects.equals(avatarPath, profile.getAvatarUrlPath())) {
278 downloadProfileAvatar(addressResolver.resolveSignalServiceAddress(recipientId), avatarPath, profileKey);
279 var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
280 account.getProfileStore().storeProfile(recipientId, builder.withAvatarUrlPath(avatarPath).build());
281 }
282 }
283
284 private ProfileAndCredential retrieveProfileSync(
285 RecipientId recipientId, SignalServiceProfile.RequestType requestType
286 ) throws IOException {
287 try {
288 return retrieveProfile(recipientId, requestType).blockingGet();
289 } catch (RuntimeException e) {
290 if (e.getCause() instanceof PushNetworkException) {
291 throw (PushNetworkException) e.getCause();
292 } else if (e.getCause() instanceof NotFoundException) {
293 throw (NotFoundException) e.getCause();
294 } else {
295 throw new IOException(e);
296 }
297 }
298 }
299
300 private Single<ProfileAndCredential> retrieveProfile(
301 RecipientId recipientId, SignalServiceProfile.RequestType requestType
302 ) {
303 var unidentifiedAccess = getUnidentifiedAccess(recipientId);
304 var profileKey = Optional.fromNullable(account.getProfileStore().getProfileKey(recipientId));
305
306 final var address = addressResolver.resolveSignalServiceAddress(recipientId);
307 return retrieveProfile(address, profileKey, unidentifiedAccess, requestType);
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();
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 SignalServiceAddress address, String avatarPath, ProfileKey profileKey
335 ) {
336 if (avatarPath == null) {
337 try {
338 avatarStore.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 avatarStore.storeProfileAvatar(address,
347 outputStream -> retrieveProfileAvatar(avatarPath, profileKey, outputStream));
348 } catch (Throwable e) {
349 if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
350 Thread.currentThread().interrupt();
351 }
352 logger.warn("Failed to download profile avatar, ignoring: {}", e.getMessage());
353 }
354 }
355
356 private void retrieveProfileAvatar(
357 String avatarPath, ProfileKey profileKey, OutputStream outputStream
358 ) throws IOException {
359 var tmpFile = IOUtils.createTempFile();
360 try (var input = dependencies.getMessageReceiver()
361 .retrieveProfileAvatar(avatarPath,
362 tmpFile,
363 profileKey,
364 ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) {
365 // Use larger buffer size to prevent AssertionError: Need: 12272 but only have: 8192 ...
366 IOUtils.copyStream(input, outputStream, (int) ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE);
367 } finally {
368 try {
369 Files.delete(tmpFile.toPath());
370 } catch (IOException e) {
371 logger.warn("Failed to delete received profile avatar temp file “{}”, ignoring: {}",
372 tmpFile,
373 e.getMessage());
374 }
375 }
376 }
377
378 private Optional<UnidentifiedAccess> getUnidentifiedAccess(RecipientId recipientId) {
379 var unidentifiedAccess = unidentifiedAccessProvider.getAccessFor(recipientId);
380
381 if (unidentifiedAccess.isPresent()) {
382 return unidentifiedAccess.get().getTargetUnidentifiedAccess();
383 }
384
385 return Optional.absent();
386 }
387 }