1 package org
.asamk
.signal
.manager
.helper
;
3 import org
.asamk
.signal
.manager
.api
.GroupNotFoundException
;
4 import org
.asamk
.signal
.manager
.api
.NotAGroupMemberException
;
5 import org
.asamk
.signal
.manager
.api
.PhoneNumberSharingMode
;
6 import org
.asamk
.signal
.manager
.api
.Profile
;
7 import org
.asamk
.signal
.manager
.config
.ServiceConfig
;
8 import org
.asamk
.signal
.manager
.internal
.SignalDependencies
;
9 import org
.asamk
.signal
.manager
.jobs
.SyncStorageJob
;
10 import org
.asamk
.signal
.manager
.storage
.SignalAccount
;
11 import org
.asamk
.signal
.manager
.storage
.groups
.GroupInfoV2
;
12 import org
.asamk
.signal
.manager
.storage
.recipients
.RecipientAddress
;
13 import org
.asamk
.signal
.manager
.storage
.recipients
.RecipientId
;
14 import org
.asamk
.signal
.manager
.util
.IOUtils
;
15 import org
.asamk
.signal
.manager
.util
.KeyUtils
;
16 import org
.asamk
.signal
.manager
.util
.PaymentUtils
;
17 import org
.asamk
.signal
.manager
.util
.ProfileUtils
;
18 import org
.asamk
.signal
.manager
.util
.Utils
;
19 import org
.jetbrains
.annotations
.Nullable
;
20 import org
.signal
.libsignal
.protocol
.IdentityKey
;
21 import org
.signal
.libsignal
.protocol
.InvalidKeyException
;
22 import org
.signal
.libsignal
.zkgroup
.profiles
.ExpiringProfileKeyCredential
;
23 import org
.signal
.libsignal
.zkgroup
.profiles
.ProfileKey
;
24 import org
.slf4j
.Logger
;
25 import org
.slf4j
.LoggerFactory
;
26 import org
.whispersystems
.signalservice
.api
.NetworkResultUtil
;
27 import org
.whispersystems
.signalservice
.api
.crypto
.SealedSenderAccess
;
28 import org
.whispersystems
.signalservice
.api
.profiles
.AvatarUploadParams
;
29 import org
.whispersystems
.signalservice
.api
.profiles
.ProfileAndCredential
;
30 import org
.whispersystems
.signalservice
.api
.profiles
.SignalServiceProfile
;
31 import org
.whispersystems
.signalservice
.api
.push
.SignalServiceAddress
;
32 import org
.whispersystems
.signalservice
.api
.push
.exceptions
.NotFoundException
;
33 import org
.whispersystems
.signalservice
.api
.push
.exceptions
.PushNetworkException
;
34 import org
.whispersystems
.signalservice
.api
.services
.ProfileService
;
35 import org
.whispersystems
.signalservice
.api
.util
.ExpiringProfileCredentialUtil
;
37 import java
.io
.IOException
;
38 import java
.io
.OutputStream
;
39 import java
.nio
.file
.Files
;
40 import java
.util
.Base64
;
41 import java
.util
.Collection
;
42 import java
.util
.List
;
43 import java
.util
.Locale
;
44 import java
.util
.Objects
;
45 import java
.util
.Optional
;
48 import io
.reactivex
.rxjava3
.core
.Flowable
;
49 import io
.reactivex
.rxjava3
.core
.Maybe
;
50 import io
.reactivex
.rxjava3
.core
.Single
;
52 public final class ProfileHelper
{
54 private static final Logger logger
= LoggerFactory
.getLogger(ProfileHelper
.class);
56 private final SignalAccount account
;
57 private final SignalDependencies dependencies
;
58 private final Context context
;
60 public ProfileHelper(final Context context
) {
61 this.account
= context
.getAccount();
62 this.dependencies
= context
.getDependencies();
63 this.context
= context
;
66 public void rotateProfileKey() throws IOException
{
67 // refresh our profile, before creating a new profile key
69 var profileKey
= KeyUtils
.createProfileKey();
70 account
.setProfileKey(profileKey
);
71 context
.getAccountHelper().updateAccountAttributes();
72 setProfile(true, true, null, null, null, null, null, null);
73 account
.getRecipientStore().rotateSelfStorageId();
74 context
.getJobExecutor().enqueueJob(new SyncStorageJob());
76 final var recipientIds
= account
.getRecipientStore().getRecipientIdsWithEnabledProfileSharing();
77 for (final var recipientId
: recipientIds
) {
78 context
.getSendHelper().sendProfileKey(recipientId
);
81 final var selfRecipientId
= account
.getSelfRecipientId();
82 final var activeGroupIds
= account
.getGroupStore()
85 .filter(g
-> g
instanceof GroupInfoV2
&& g
.isMember(selfRecipientId
) && g
.isProfileSharingEnabled())
86 .map(g
-> (GroupInfoV2
) g
)
87 .map(GroupInfoV2
::getGroupId
)
89 for (final var groupId
: activeGroupIds
) {
91 context
.getGroupHelper().updateGroupProfileKey(groupId
);
92 } catch (GroupNotFoundException
| NotAGroupMemberException
| IOException e
) {
93 logger
.warn("Failed to update group profile key: {}", e
.getMessage());
98 public Profile
getRecipientProfile(RecipientId recipientId
) {
99 return getRecipientProfile(recipientId
, false);
102 public List
<Profile
> getRecipientProfiles(Collection
<RecipientId
> recipientIds
) {
103 return getRecipientProfiles(recipientIds
, false);
106 public void refreshRecipientProfile(RecipientId recipientId
) {
107 getRecipientProfile(recipientId
, true);
110 public void refreshRecipientProfiles(Collection
<RecipientId
> recipientIds
) {
111 getRecipientProfiles(recipientIds
, true);
114 public List
<ExpiringProfileKeyCredential
> getExpiringProfileKeyCredential(List
<RecipientId
> recipientIds
) {
115 final var profileFetches
= Flowable
.fromIterable(recipientIds
)
116 .filter(recipientId
-> !ExpiringProfileCredentialUtil
.isValid(account
.getProfileStore()
117 .getExpiringProfileKeyCredential(recipientId
)))
118 .map(recipientId
-> retrieveProfile(recipientId
,
119 SignalServiceProfile
.RequestType
.PROFILE_AND_CREDENTIAL
).onErrorComplete());
120 Maybe
.merge(profileFetches
, 10).blockingSubscribe();
122 return recipientIds
.stream().map(r
-> account
.getProfileStore().getExpiringProfileKeyCredential(r
)).toList();
125 public ExpiringProfileKeyCredential
getExpiringProfileKeyCredential(RecipientId recipientId
) {
126 var profileKeyCredential
= account
.getProfileStore().getExpiringProfileKeyCredential(recipientId
);
127 if (ExpiringProfileCredentialUtil
.isValid(profileKeyCredential
)) {
128 return profileKeyCredential
;
132 blockingGetProfile(retrieveProfile(recipientId
, SignalServiceProfile
.RequestType
.PROFILE_AND_CREDENTIAL
));
133 } catch (IOException e
) {
134 logger
.warn("Failed to retrieve profile key credential, ignoring: {}", e
.getMessage());
138 return account
.getProfileStore().getExpiringProfileKeyCredential(recipientId
);
142 * @param givenName if null, the previous givenName will be kept
143 * @param familyName if null, the previous familyName will be kept
144 * @param about if null, the previous about text will be kept
145 * @param aboutEmoji if null, the previous about emoji will be kept
146 * @param avatar if avatar is null the image from the local avatar store is used (if present),
148 public void setProfile(
150 final String familyName
,
153 Optional
<String
> avatar
,
154 byte[] mobileCoinAddress
155 ) throws IOException
{
156 setProfile(true, false, givenName
, familyName
, about
, aboutEmoji
, avatar
, mobileCoinAddress
);
159 public void setProfile(
160 boolean uploadProfile
,
161 boolean forceUploadAvatar
,
163 final String familyName
,
166 Optional
<String
> avatar
,
167 byte[] mobileCoinAddress
168 ) throws IOException
{
169 var profile
= getSelfProfile();
170 var builder
= profile
== null ? Profile
.newBuilder() : Profile
.newBuilder(profile
);
171 if (givenName
!= null) {
172 builder
.withGivenName(givenName
);
174 if (familyName
!= null) {
175 builder
.withFamilyName(familyName
);
178 builder
.withAbout(about
);
180 if (aboutEmoji
!= null) {
181 builder
.withAboutEmoji(aboutEmoji
);
183 if (mobileCoinAddress
!= null) {
184 builder
.withMobileCoinAddress(mobileCoinAddress
);
186 var newProfile
= builder
.build();
189 final var streamDetails
= avatar
!= null && avatar
.isPresent()
190 ? Utils
.createStreamDetails(avatar
.get())
192 : forceUploadAvatar
&& avatar
== null ? context
.getAvatarStore()
193 .retrieveProfileAvatar(account
.getSelfRecipientAddress()) : null;
194 try (streamDetails
) {
195 final var avatarUploadParams
= streamDetails
!= null
196 ? AvatarUploadParams
.forAvatar(streamDetails
)
197 : avatar
== null ? AvatarUploadParams
.unchanged(true) : AvatarUploadParams
.unchanged(false);
198 final var paymentsAddress
= Optional
.ofNullable(newProfile
.getMobileCoinAddress())
199 .map(address
-> PaymentUtils
.signPaymentsAddress(address
,
200 account
.getAciIdentityKeyPair().getPrivateKey()))
202 logger
.debug("Uploading new profile");
203 final var avatarPath
= NetworkResultUtil
.toSetProfileLegacy(dependencies
.getProfileApi()
204 .setVersionedProfile(account
.getAci(),
205 account
.getProfileKey(),
206 newProfile
.getInternalServiceName(),
207 newProfile
.getAbout() == null ?
"" : newProfile
.getAbout(),
208 newProfile
.getAboutEmoji() == null ?
"" : newProfile
.getAboutEmoji(),
211 List
.of(/* TODO implement support for badges */),
212 account
.getConfigurationStore().getPhoneNumberSharingMode()
213 == PhoneNumberSharingMode
.EVERYBODY
));
214 if (!avatarUploadParams
.keepTheSame
) {
215 builder
.withAvatarUrlPath(avatarPath
);
217 newProfile
= builder
.build();
221 if (avatar
!= null) {
222 if (avatar
.isPresent()) {
223 try (final var streamDetails
= Utils
.createStreamDetails(avatar
.get()).first()) {
224 context
.getAvatarStore()
225 .storeProfileAvatar(account
.getSelfRecipientAddress(),
226 outputStream
-> IOUtils
.copyStream(streamDetails
.getStream(), outputStream
));
229 context
.getAvatarStore().deleteProfileAvatar(account
.getSelfRecipientAddress());
232 account
.getProfileStore().storeProfile(account
.getSelfRecipientId(), newProfile
);
235 public Profile
getSelfProfile() {
236 return getRecipientProfile(account
.getSelfRecipientId());
239 private List
<Profile
> getRecipientProfiles(Collection
<RecipientId
> recipientIds
, boolean force
) {
240 final var profileStore
= account
.getProfileStore();
241 final var profileFetches
= Flowable
.fromIterable(recipientIds
)
242 .filter(recipientId
-> force
|| isProfileRefreshRequired(profileStore
.getProfile(recipientId
)))
243 .map(recipientId
-> retrieveProfile(recipientId
,
244 SignalServiceProfile
.RequestType
.PROFILE
).onErrorComplete());
245 Maybe
.merge(profileFetches
, 10).blockingSubscribe();
247 return recipientIds
.stream().map(profileStore
::getProfile
).toList();
250 private Profile
getRecipientProfile(RecipientId recipientId
, boolean force
) {
251 var profile
= account
.getProfileStore().getProfile(recipientId
);
253 if (!force
&& !isProfileRefreshRequired(profile
)) {
258 blockingGetProfile(retrieveProfile(recipientId
, SignalServiceProfile
.RequestType
.PROFILE
));
259 } catch (IOException e
) {
260 logger
.warn("Failed to retrieve profile, ignoring: {}", e
.getMessage());
263 return account
.getProfileStore().getProfile(recipientId
);
266 private boolean isProfileRefreshRequired(final Profile profile
) {
267 if (profile
== null) {
270 // Profiles are cached for 6h before retrieving them again, unless forced
271 final var now
= System
.currentTimeMillis();
272 return now
- profile
.getLastUpdateTimestamp() >= 6 * 60 * 60 * 1000;
275 private Profile
decryptProfileAndDownloadAvatar(
276 final RecipientId recipientId
,
277 final ProfileKey profileKey
,
278 final SignalServiceProfile encryptedProfile
280 final var avatarPath
= encryptedProfile
.getAvatar();
281 downloadProfileAvatar(recipientId
, avatarPath
, profileKey
);
283 return ProfileUtils
.decryptProfile(profileKey
, encryptedProfile
);
286 public void downloadProfileAvatar(
287 final RecipientId recipientId
,
288 final String avatarPath
,
289 final ProfileKey profileKey
291 var profile
= account
.getProfileStore().getProfile(recipientId
);
292 if (profile
== null || !Objects
.equals(avatarPath
, profile
.getAvatarUrlPath())) {
293 logger
.trace("Downloading profile avatar for {}", recipientId
);
294 downloadProfileAvatar(account
.getRecipientAddressResolver().resolveRecipientAddress(recipientId
),
297 var builder
= profile
== null ? Profile
.newBuilder() : Profile
.newBuilder(profile
);
298 account
.getProfileStore().storeProfile(recipientId
, builder
.withAvatarUrlPath(avatarPath
).build());
302 private ProfileAndCredential
blockingGetProfile(Single
<ProfileAndCredential
> profile
) throws IOException
{
304 return profile
.blockingGet();
305 } catch (RuntimeException e
) {
306 if (e
.getCause() instanceof PushNetworkException
) {
307 throw (PushNetworkException
) e
.getCause();
308 } else if (e
.getCause() instanceof NotFoundException
) {
309 throw (NotFoundException
) e
.getCause();
311 throw new IOException(e
);
316 private Single
<ProfileAndCredential
> retrieveProfile(
317 RecipientId recipientId
,
318 SignalServiceProfile
.RequestType requestType
320 var unidentifiedAccess
= getUnidentifiedAccess(recipientId
);
321 var profileKey
= Optional
.ofNullable(account
.getProfileStore().getProfileKey(recipientId
));
323 logger
.trace("Retrieving profile for {} {}",
325 profileKey
.isPresent() ?
"with profile key" : "without profile key");
326 final var address
= context
.getRecipientHelper().resolveSignalServiceAddress(recipientId
);
327 return retrieveProfile(address
, profileKey
, unidentifiedAccess
, requestType
).doOnSuccess(p
-> {
328 logger
.trace("Got new profile for {}", recipientId
);
329 final var encryptedProfile
= p
.getProfile();
331 if (requestType
== SignalServiceProfile
.RequestType
.PROFILE_AND_CREDENTIAL
332 || !ExpiringProfileCredentialUtil
.isValid(account
.getProfileStore()
333 .getExpiringProfileKeyCredential(recipientId
))) {
334 logger
.trace("Storing profile credential");
335 final var profileKeyCredential
= p
.getExpiringProfileKeyCredential().orElse(null);
336 account
.getProfileStore().storeExpiringProfileKeyCredential(recipientId
, profileKeyCredential
);
339 final var profile
= account
.getProfileStore().getProfile(recipientId
);
341 Profile newProfile
= null;
342 if (profileKey
.isPresent()) {
343 logger
.trace("Decrypting profile");
344 newProfile
= decryptProfileAndDownloadAvatar(recipientId
, profileKey
.get(), encryptedProfile
);
347 if (newProfile
== null) {
349 profile
== null ? Profile
.newBuilder() : Profile
.newBuilder(profile
)
350 ).withLastUpdateTimestamp(System
.currentTimeMillis())
351 .withUnidentifiedAccessMode(ProfileUtils
.getUnidentifiedAccessMode(encryptedProfile
, null))
352 .withCapabilities(ProfileUtils
.getCapabilities(encryptedProfile
))
356 if (recipientId
.equals(account
.getSelfRecipientId())) {
357 final var isUnrestricted
= encryptedProfile
.isUnrestrictedUnidentifiedAccess();
358 if (account
.isUnrestrictedUnidentifiedAccess() != isUnrestricted
) {
359 account
.setUnrestrictedUnidentifiedAccess(isUnrestricted
);
361 if (account
.isPrimaryDevice() && profile
!= null && newProfile
.getCapabilities()
362 .contains(Profile
.Capability
.storageServiceEncryptionV2Capability
) && !profile
.getCapabilities()
363 .contains(Profile
.Capability
.storageServiceEncryptionV2Capability
)) {
364 context
.getJobExecutor().enqueueJob(new SyncStorageJob(true));
369 logger
.trace("Storing identity");
370 final var identityKey
= new IdentityKey(Base64
.getDecoder().decode(encryptedProfile
.getIdentityKey()));
371 account
.getIdentityKeyStore().saveIdentity(p
.getProfile().getServiceId(), identityKey
);
372 } catch (InvalidKeyException ignored
) {
373 logger
.warn("Got invalid identity key in profile for {}",
374 context
.getRecipientHelper().resolveSignalServiceAddress(recipientId
).getIdentifier());
377 logger
.trace("Storing profile");
378 account
.getProfileStore().storeProfile(recipientId
, newProfile
);
379 account
.getRecipientStore().markRegistered(recipientId
, true);
381 logger
.trace("Done handling retrieved profile");
383 logger
.warn("Failed to retrieve profile, ignoring: {}", e
.getMessage());
384 final var profile
= account
.getProfileStore().getProfile(recipientId
);
385 final var newProfile
= (
386 profile
== null ? Profile
.newBuilder() : Profile
.newBuilder(profile
)
387 ).withLastUpdateTimestamp(System
.currentTimeMillis())
388 .withUnidentifiedAccessMode(Profile
.UnidentifiedAccessMode
.UNKNOWN
)
389 .withCapabilities(Set
.of())
391 if (e
instanceof NotFoundException
) {
392 logger
.debug("Marking recipient {} as unregistered after 404 profile fetch.", recipientId
);
393 account
.getRecipientStore().markRegistered(recipientId
, false);
396 account
.getProfileStore().storeProfile(recipientId
, newProfile
);
400 private Single
<ProfileAndCredential
> retrieveProfile(
401 SignalServiceAddress address
,
402 Optional
<ProfileKey
> profileKey
,
403 @Nullable SealedSenderAccess unidentifiedAccess
,
404 SignalServiceProfile
.RequestType requestType
406 final var profileService
= dependencies
.getProfileService();
407 final var locale
= Utils
.getDefaultLocale(Locale
.US
);
409 return profileService
.getProfile(address
, profileKey
, unidentifiedAccess
, requestType
, locale
).map(pair
-> {
410 var processor
= new ProfileService
.ProfileResponseProcessor(pair
);
411 if (processor
.hasResult()) {
412 return processor
.getResult();
413 } else if (processor
.notFound()) {
414 throw new NotFoundException("Profile not found");
416 throw pair
.getExecutionError()
417 .or(pair
::getApplicationError
)
418 .orElseThrow(() -> new IOException("Unknown error while retrieving profile"));
423 private void downloadProfileAvatar(RecipientAddress address
, String avatarPath
, ProfileKey profileKey
) {
424 if (avatarPath
== null) {
426 context
.getAvatarStore().deleteProfileAvatar(address
);
427 } catch (IOException e
) {
428 logger
.warn("Failed to delete local profile avatar, ignoring: {}", e
.getMessage());
434 context
.getAvatarStore()
435 .storeProfileAvatar(address
,
436 outputStream
-> retrieveProfileAvatar(avatarPath
, profileKey
, outputStream
));
437 } catch (Throwable e
) {
438 logger
.warn("Failed to download profile avatar, ignoring: {}", e
.getMessage());
442 private void retrieveProfileAvatar(
444 ProfileKey profileKey
,
445 OutputStream outputStream
446 ) throws IOException
{
447 var tmpFile
= IOUtils
.createTempFile();
448 try (var input
= dependencies
.getMessageReceiver()
449 .retrieveProfileAvatar(avatarPath
,
452 ServiceConfig
.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE
)) {
453 // Use larger buffer size to prevent AssertionError: Need: 12272 but only have: 8192 ...
454 IOUtils
.copyStream(input
, outputStream
, (int) ServiceConfig
.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE
);
457 Files
.delete(tmpFile
.toPath());
458 } catch (IOException e
) {
459 logger
.warn("Failed to delete received profile avatar temp file “{}”, ignoring: {}",
466 private @Nullable SealedSenderAccess
getUnidentifiedAccess(RecipientId recipientId
) {
467 return context
.getUnidentifiedAccessHelper().getSealedSenderAccessFor(recipientId
, true);