1 package org
.asamk
.signal
.manager
.helper
;
3 import org
.asamk
.signal
.manager
.SignalDependencies
;
4 import org
.asamk
.signal
.manager
.config
.ServiceConfig
;
5 import org
.asamk
.signal
.manager
.groups
.GroupNotFoundException
;
6 import org
.asamk
.signal
.manager
.groups
.NotAGroupMemberException
;
7 import org
.asamk
.signal
.manager
.storage
.SignalAccount
;
8 import org
.asamk
.signal
.manager
.storage
.groups
.GroupInfoV2
;
9 import org
.asamk
.signal
.manager
.storage
.recipients
.Profile
;
10 import org
.asamk
.signal
.manager
.storage
.recipients
.RecipientAddress
;
11 import org
.asamk
.signal
.manager
.storage
.recipients
.RecipientId
;
12 import org
.asamk
.signal
.manager
.util
.IOUtils
;
13 import org
.asamk
.signal
.manager
.util
.KeyUtils
;
14 import org
.asamk
.signal
.manager
.util
.PaymentUtils
;
15 import org
.asamk
.signal
.manager
.util
.ProfileUtils
;
16 import org
.asamk
.signal
.manager
.util
.Utils
;
17 import org
.signal
.libsignal
.protocol
.IdentityKey
;
18 import org
.signal
.libsignal
.protocol
.InvalidKeyException
;
19 import org
.signal
.libsignal
.zkgroup
.profiles
.ProfileKey
;
20 import org
.signal
.libsignal
.zkgroup
.profiles
.ProfileKeyCredential
;
21 import org
.slf4j
.Logger
;
22 import org
.slf4j
.LoggerFactory
;
23 import org
.whispersystems
.signalservice
.api
.crypto
.UnidentifiedAccess
;
24 import org
.whispersystems
.signalservice
.api
.profiles
.AvatarUploadParams
;
25 import org
.whispersystems
.signalservice
.api
.profiles
.ProfileAndCredential
;
26 import org
.whispersystems
.signalservice
.api
.profiles
.SignalServiceProfile
;
27 import org
.whispersystems
.signalservice
.api
.push
.SignalServiceAddress
;
28 import org
.whispersystems
.signalservice
.api
.push
.exceptions
.NotFoundException
;
29 import org
.whispersystems
.signalservice
.api
.push
.exceptions
.PushNetworkException
;
30 import org
.whispersystems
.signalservice
.api
.services
.ProfileService
;
33 import java
.io
.IOException
;
34 import java
.io
.OutputStream
;
35 import java
.nio
.file
.Files
;
36 import java
.util
.Base64
;
37 import java
.util
.Collection
;
38 import java
.util
.Date
;
39 import java
.util
.List
;
40 import java
.util
.Locale
;
41 import java
.util
.Objects
;
42 import java
.util
.Optional
;
45 import io
.reactivex
.rxjava3
.core
.Flowable
;
46 import io
.reactivex
.rxjava3
.core
.Maybe
;
47 import io
.reactivex
.rxjava3
.core
.Single
;
49 public final class ProfileHelper
{
51 private final static Logger logger
= LoggerFactory
.getLogger(ProfileHelper
.class);
53 private final SignalAccount account
;
54 private final SignalDependencies dependencies
;
55 private final Context context
;
57 public ProfileHelper(final Context context
) {
58 this.account
= context
.getAccount();
59 this.dependencies
= context
.getDependencies();
60 this.context
= context
;
63 public void rotateProfileKey() throws IOException
{
64 var profileKey
= KeyUtils
.createProfileKey();
65 account
.setProfileKey(profileKey
);
66 context
.getAccountHelper().updateAccountAttributes();
67 setProfile(true, true, null, null, null, null, null);
68 // TODO update profile key in storage
70 final var recipientIds
= account
.getRecipientStore().getRecipientIdsWithEnabledProfileSharing();
71 for (final var recipientId
: recipientIds
) {
72 context
.getSendHelper().sendProfileKey(recipientId
);
75 final var selfRecipientId
= account
.getSelfRecipientId();
76 final var activeGroupIds
= account
.getGroupStore()
79 .filter(g
-> g
instanceof GroupInfoV2
&& g
.isMember(selfRecipientId
))
80 .map(g
-> (GroupInfoV2
) g
)
81 .map(GroupInfoV2
::getGroupId
)
83 for (final var groupId
: activeGroupIds
) {
85 context
.getGroupHelper().updateGroupProfileKey(groupId
);
86 } catch (GroupNotFoundException
| NotAGroupMemberException
| IOException e
) {
87 logger
.warn("Failed to update group profile key: {}", e
.getMessage());
92 public Profile
getRecipientProfile(RecipientId recipientId
) {
93 return getRecipientProfile(recipientId
, false);
96 public List
<Profile
> getRecipientProfiles(Collection
<RecipientId
> recipientIds
) {
97 return getRecipientProfiles(recipientIds
, false);
100 public void refreshRecipientProfile(RecipientId recipientId
) {
101 getRecipientProfile(recipientId
, true);
104 public void refreshRecipientProfiles(Collection
<RecipientId
> recipientIds
) {
105 getRecipientProfiles(recipientIds
, true);
108 public List
<ProfileKeyCredential
> getRecipientProfileKeyCredential(List
<RecipientId
> recipientIds
) {
110 account
.getRecipientStore().setBulkUpdating(true);
111 final var profileFetches
= Flowable
.fromIterable(recipientIds
)
112 .filter(recipientId
-> account
.getProfileStore().getProfileKeyCredential(recipientId
) == null)
113 .map(recipientId
-> retrieveProfile(recipientId
,
114 SignalServiceProfile
.RequestType
.PROFILE_AND_CREDENTIAL
).onErrorComplete());
115 Maybe
.merge(profileFetches
, 10).blockingSubscribe();
117 account
.getRecipientStore().setBulkUpdating(false);
120 return recipientIds
.stream().map(r
-> account
.getProfileStore().getProfileKeyCredential(r
)).toList();
123 public ProfileKeyCredential
getRecipientProfileKeyCredential(RecipientId recipientId
) {
124 var profileKeyCredential
= account
.getProfileStore().getProfileKeyCredential(recipientId
);
125 if (profileKeyCredential
!= null) {
126 return profileKeyCredential
;
130 blockingGetProfile(retrieveProfile(recipientId
, SignalServiceProfile
.RequestType
.PROFILE_AND_CREDENTIAL
));
131 } catch (IOException e
) {
132 logger
.warn("Failed to retrieve profile key credential, ignoring: {}", e
.getMessage());
136 return account
.getProfileStore().getProfileKeyCredential(recipientId
);
140 * @param givenName if null, the previous givenName will be kept
141 * @param familyName if null, the previous familyName will be kept
142 * @param about if null, the previous about text will be kept
143 * @param aboutEmoji if null, the previous about emoji will be kept
144 * @param avatar if avatar is null the image from the local avatar store is used (if present),
146 public void setProfile(
147 String givenName
, final String familyName
, String about
, String aboutEmoji
, Optional
<File
> avatar
148 ) throws IOException
{
149 setProfile(true, false, givenName
, familyName
, about
, aboutEmoji
, avatar
);
152 public void setProfile(
153 boolean uploadProfile
,
154 boolean forceUploadAvatar
,
156 final String familyName
,
159 Optional
<File
> avatar
160 ) throws IOException
{
161 var profile
= getSelfProfile();
162 var builder
= profile
== null ? Profile
.newBuilder() : Profile
.newBuilder(profile
);
163 if (givenName
!= null) {
164 builder
.withGivenName(givenName
);
166 if (familyName
!= null) {
167 builder
.withFamilyName(familyName
);
170 builder
.withAbout(about
);
172 if (aboutEmoji
!= null) {
173 builder
.withAboutEmoji(aboutEmoji
);
175 var newProfile
= builder
.build();
178 final var streamDetails
= avatar
!= null && avatar
.isPresent()
179 ? Utils
.createStreamDetailsFromFile(avatar
.get())
180 : forceUploadAvatar
&& avatar
== null ? context
.getAvatarStore()
181 .retrieveProfileAvatar(account
.getSelfRecipientAddress()) : null;
182 try (streamDetails
) {
183 final var avatarUploadParams
= streamDetails
!= null
184 ? AvatarUploadParams
.forAvatar(streamDetails
)
185 : avatar
== null ? AvatarUploadParams
.unchanged(true) : AvatarUploadParams
.unchanged(false);
186 final var paymentsAddress
= Optional
.ofNullable(newProfile
.getMobileCoinAddress())
187 .map(address
-> PaymentUtils
.signPaymentsAddress(address
,
188 account
.getAciIdentityKeyPair().getPrivateKey()));
189 logger
.debug("Uploading new profile");
190 final var avatarPath
= dependencies
.getAccountManager()
191 .setVersionedProfile(account
.getAci(),
192 account
.getProfileKey(),
193 newProfile
.getInternalServiceName(),
194 newProfile
.getAbout() == null ?
"" : newProfile
.getAbout(),
195 newProfile
.getAboutEmoji() == null ?
"" : newProfile
.getAboutEmoji(),
198 List
.of(/* TODO implement support for badges */));
199 if (!avatarUploadParams
.keepTheSame
) {
200 builder
.withAvatarUrlPath(avatarPath
.orElse(null));
202 newProfile
= builder
.build();
206 if (avatar
!= null) {
207 if (avatar
.isPresent()) {
208 context
.getAvatarStore()
209 .storeProfileAvatar(account
.getSelfRecipientAddress(),
210 outputStream
-> IOUtils
.copyFileToStream(avatar
.get(), outputStream
));
212 context
.getAvatarStore().deleteProfileAvatar(account
.getSelfRecipientAddress());
215 account
.getProfileStore().storeProfile(account
.getSelfRecipientId(), newProfile
);
218 public Profile
getSelfProfile() {
219 return getRecipientProfile(account
.getSelfRecipientId());
222 private List
<Profile
> getRecipientProfiles(Collection
<RecipientId
> recipientIds
, boolean force
) {
223 final var profileStore
= account
.getProfileStore();
225 account
.getRecipientStore().setBulkUpdating(true);
226 final var profileFetches
= Flowable
.fromIterable(recipientIds
)
227 .filter(recipientId
-> force
|| isProfileRefreshRequired(profileStore
.getProfile(recipientId
)))
228 .map(recipientId
-> retrieveProfile(recipientId
,
229 SignalServiceProfile
.RequestType
.PROFILE
).onErrorComplete());
230 Maybe
.merge(profileFetches
, 10).blockingSubscribe();
232 account
.getRecipientStore().setBulkUpdating(false);
235 return recipientIds
.stream().map(profileStore
::getProfile
).toList();
238 private Profile
getRecipientProfile(RecipientId recipientId
, boolean force
) {
239 var profile
= account
.getProfileStore().getProfile(recipientId
);
241 if (!force
&& !isProfileRefreshRequired(profile
)) {
246 blockingGetProfile(retrieveProfile(recipientId
, SignalServiceProfile
.RequestType
.PROFILE
));
247 } catch (IOException e
) {
248 logger
.warn("Failed to retrieve profile, ignoring: {}", e
.getMessage());
251 return account
.getProfileStore().getProfile(recipientId
);
254 private boolean isProfileRefreshRequired(final Profile profile
) {
255 if (profile
== null) {
258 // Profiles are cached for 6h before retrieving them again, unless forced
259 final var now
= System
.currentTimeMillis();
260 return now
- profile
.getLastUpdateTimestamp() >= 6 * 60 * 60 * 1000;
263 private SignalServiceProfile
retrieveProfileSync(String username
) throws IOException
{
264 final var locale
= Utils
.getDefaultLocale(Locale
.US
);
265 return dependencies
.getMessageReceiver().retrieveProfileByUsername(username
, Optional
.empty(), locale
);
268 private Profile
decryptProfileAndDownloadAvatar(
269 final RecipientId recipientId
, final ProfileKey profileKey
, final SignalServiceProfile encryptedProfile
271 final var avatarPath
= encryptedProfile
.getAvatar();
272 downloadProfileAvatar(recipientId
, avatarPath
, profileKey
);
274 return ProfileUtils
.decryptProfile(profileKey
, encryptedProfile
);
277 public void downloadProfileAvatar(
278 final RecipientId recipientId
, final String avatarPath
, final ProfileKey profileKey
280 var profile
= account
.getProfileStore().getProfile(recipientId
);
281 if (profile
== null || !Objects
.equals(avatarPath
, profile
.getAvatarUrlPath())) {
282 logger
.trace("Downloading profile avatar for {}", recipientId
);
283 downloadProfileAvatar(account
.getRecipientStore().resolveRecipientAddress(recipientId
),
286 var builder
= profile
== null ? Profile
.newBuilder() : Profile
.newBuilder(profile
);
287 account
.getProfileStore().storeProfile(recipientId
, builder
.withAvatarUrlPath(avatarPath
).build());
291 private ProfileAndCredential
blockingGetProfile(Single
<ProfileAndCredential
> profile
) throws IOException
{
293 return profile
.blockingGet();
294 } catch (RuntimeException e
) {
295 if (e
.getCause() instanceof PushNetworkException
) {
296 throw (PushNetworkException
) e
.getCause();
297 } else if (e
.getCause() instanceof NotFoundException
) {
298 throw (NotFoundException
) e
.getCause();
300 throw new IOException(e
);
305 private Single
<ProfileAndCredential
> retrieveProfile(
306 RecipientId recipientId
, SignalServiceProfile
.RequestType requestType
308 var unidentifiedAccess
= getUnidentifiedAccess(recipientId
);
309 var profileKey
= Optional
.ofNullable(account
.getProfileStore().getProfileKey(recipientId
));
311 logger
.trace("Retrieving profile for {} {}",
313 profileKey
.isPresent() ?
"with profile key" : "without profile key");
314 final var address
= context
.getRecipientHelper().resolveSignalServiceAddress(recipientId
);
315 return retrieveProfile(address
, profileKey
, unidentifiedAccess
, requestType
).doOnSuccess(p
-> {
316 logger
.trace("Got new profile for {}", recipientId
);
317 final var encryptedProfile
= p
.getProfile();
319 if (requestType
== SignalServiceProfile
.RequestType
.PROFILE_AND_CREDENTIAL
320 || account
.getProfileStore().getProfileKeyCredential(recipientId
) == null) {
321 logger
.trace("Storing profile credential");
322 final var profileKeyCredential
= p
.getProfileKeyCredential().orElse(null);
323 account
.getProfileStore().storeProfileKeyCredential(recipientId
, profileKeyCredential
);
326 final var profile
= account
.getProfileStore().getProfile(recipientId
);
328 Profile newProfile
= null;
329 if (profileKey
.isPresent()) {
330 logger
.trace("Decrypting profile");
331 newProfile
= decryptProfileAndDownloadAvatar(recipientId
, profileKey
.get(), encryptedProfile
);
334 if (newProfile
== null) {
336 profile
== null ? Profile
.newBuilder() : Profile
.newBuilder(profile
)
337 ).withLastUpdateTimestamp(System
.currentTimeMillis())
338 .withUnidentifiedAccessMode(ProfileUtils
.getUnidentifiedAccessMode(encryptedProfile
, null))
339 .withCapabilities(ProfileUtils
.getCapabilities(encryptedProfile
))
344 logger
.trace("Storing identity");
345 final var identityKey
= new IdentityKey(Base64
.getDecoder().decode(encryptedProfile
.getIdentityKey()));
346 account
.getIdentityKeyStore().saveIdentity(recipientId
, identityKey
, new Date());
347 } catch (InvalidKeyException ignored
) {
348 logger
.warn("Got invalid identity key in profile for {}",
349 context
.getRecipientHelper().resolveSignalServiceAddress(recipientId
).getIdentifier());
352 logger
.trace("Storing profile");
353 account
.getProfileStore().storeProfile(recipientId
, newProfile
);
355 logger
.trace("Done handling retrieved profile");
357 logger
.warn("Failed to retrieve profile, ignoring: {}", e
.getMessage());
358 final var profile
= account
.getProfileStore().getProfile(recipientId
);
359 final var newProfile
= (
360 profile
== null ? Profile
.newBuilder() : Profile
.newBuilder(profile
)
361 ).withLastUpdateTimestamp(System
.currentTimeMillis())
362 .withUnidentifiedAccessMode(Profile
.UnidentifiedAccessMode
.UNKNOWN
)
363 .withCapabilities(Set
.of())
366 account
.getProfileStore().storeProfile(recipientId
, newProfile
);
370 private Single
<ProfileAndCredential
> retrieveProfile(
371 SignalServiceAddress address
,
372 Optional
<ProfileKey
> profileKey
,
373 Optional
<UnidentifiedAccess
> unidentifiedAccess
,
374 SignalServiceProfile
.RequestType requestType
376 final var profileService
= dependencies
.getProfileService();
377 final var locale
= Utils
.getDefaultLocale(Locale
.US
);
379 return profileService
.getProfile(address
, profileKey
, unidentifiedAccess
, requestType
, locale
).map(pair
-> {
380 var processor
= new ProfileService
.ProfileResponseProcessor(pair
);
381 if (processor
.hasResult()) {
382 return processor
.getResult();
383 } else if (processor
.notFound()) {
384 throw new NotFoundException("Profile not found");
386 throw pair
.getExecutionError()
387 .or(pair
::getApplicationError
)
388 .orElseThrow(() -> new IOException("Unknown error while retrieving profile"));
393 private void downloadProfileAvatar(
394 RecipientAddress address
, String avatarPath
, ProfileKey profileKey
396 if (avatarPath
== null) {
398 context
.getAvatarStore().deleteProfileAvatar(address
);
399 } catch (IOException e
) {
400 logger
.warn("Failed to delete local profile avatar, ignoring: {}", e
.getMessage());
406 context
.getAvatarStore()
407 .storeProfileAvatar(address
,
408 outputStream
-> retrieveProfileAvatar(avatarPath
, profileKey
, outputStream
));
409 } catch (Throwable e
) {
410 logger
.warn("Failed to download profile avatar, ignoring: {}", e
.getMessage());
414 private void retrieveProfileAvatar(
415 String avatarPath
, ProfileKey profileKey
, OutputStream outputStream
416 ) throws IOException
{
417 var tmpFile
= IOUtils
.createTempFile();
418 try (var input
= dependencies
.getMessageReceiver()
419 .retrieveProfileAvatar(avatarPath
,
422 ServiceConfig
.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE
)) {
423 // Use larger buffer size to prevent AssertionError: Need: 12272 but only have: 8192 ...
424 IOUtils
.copyStream(input
, outputStream
, (int) ServiceConfig
.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE
);
427 Files
.delete(tmpFile
.toPath());
428 } catch (IOException e
) {
429 logger
.warn("Failed to delete received profile avatar temp file “{}”, ignoring: {}",
436 private Optional
<UnidentifiedAccess
> getUnidentifiedAccess(RecipientId recipientId
) {
437 var unidentifiedAccess
= context
.getUnidentifiedAccessHelper().getAccessFor(recipientId
, true);
439 if (unidentifiedAccess
.isPresent()) {
440 return unidentifiedAccess
.get().getTargetUnidentifiedAccess();
443 return Optional
.empty();