1 package org
.asamk
.signal
.manager
.helper
;
3 import com
.google
.protobuf
.InvalidProtocolBufferException
;
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
;
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
;
42 import io
.reactivex
.rxjava3
.core
.Flowable
;
43 import io
.reactivex
.rxjava3
.core
.Maybe
;
44 import io
.reactivex
.rxjava3
.core
.Single
;
46 public final class ProfileHelper
{
48 private final static Logger logger
= LoggerFactory
.getLogger(ProfileHelper
.class);
50 private final SignalAccount account
;
51 private final SignalDependencies dependencies
;
52 private final Context context
;
54 public ProfileHelper(final Context context
) {
55 this.account
= context
.getAccount();
56 this.dependencies
= context
.getDependencies();
57 this.context
= context
;
60 public Profile
getRecipientProfile(RecipientId recipientId
) {
61 return getRecipientProfile(recipientId
, false);
64 public void refreshRecipientProfile(RecipientId recipientId
) {
65 getRecipientProfile(recipientId
, true);
68 public List
<ProfileKeyCredential
> getRecipientProfileKeyCredential(List
<RecipientId
> recipientIds
) {
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();
77 account
.getRecipientStore().setBulkUpdating(false);
80 return recipientIds
.stream().map(r
-> account
.getProfileStore().getProfileKeyCredential(r
)).toList();
83 public ProfileKeyCredential
getRecipientProfileKeyCredential(RecipientId recipientId
) {
84 var profileKeyCredential
= account
.getProfileStore().getProfileKeyCredential(recipientId
);
85 if (profileKeyCredential
!= null) {
86 return profileKeyCredential
;
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());
96 return account
.getProfileStore().getProfileKeyCredential(recipientId
);
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),
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
);
112 public void setProfile(
113 boolean uploadProfile
,
115 final String familyName
,
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
);
125 if (familyName
!= null) {
126 builder
.withFamilyName(familyName
);
129 builder
.withAbout(about
);
131 if (aboutEmoji
!= null) {
132 builder
.withAboutEmoji(aboutEmoji
);
134 var newProfile
= builder
.build();
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)
142 ? AvatarUploadParams
.forAvatar(streamDetails
)
143 : AvatarUploadParams
.unchanged(false);
144 final var paymentsAddress
= Optional
.ofNullable(newProfile
.getPaymentAddress()).map(data
-> {
146 return SignalServiceProtos
.PaymentAddress
.parseFrom(data
);
147 } catch (InvalidProtocolBufferException e
) {
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(),
159 List
.of(/* TODO */));
160 if (!avatarUploadParams
.keepTheSame
) {
161 builder
.withAvatarUrlPath(avatarPath
.orElse(null));
163 newProfile
= builder
.build();
167 if (avatar
!= null) {
168 if (avatar
.isPresent()) {
169 context
.getAvatarStore()
170 .storeProfileAvatar(account
.getSelfRecipientAddress(),
171 outputStream
-> IOUtils
.copyFileToStream(avatar
.get(), outputStream
));
173 context
.getAvatarStore().deleteProfileAvatar(account
.getSelfRecipientAddress());
176 account
.getProfileStore().storeProfile(account
.getSelfRecipientId(), newProfile
);
179 public Profile
getSelfProfile() {
180 return getRecipientProfile(account
.getSelfRecipientId());
183 public List
<Profile
> getRecipientProfile(List
<RecipientId
> recipientIds
) {
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();
192 account
.getRecipientStore().setBulkUpdating(false);
195 return recipientIds
.stream().map(r
-> account
.getProfileStore().getProfile(r
)).toList();
198 private Profile
getRecipientProfile(RecipientId recipientId
, boolean force
) {
199 var profile
= account
.getProfileStore().getProfile(recipientId
);
201 if (!force
&& !isProfileRefreshRequired(profile
)) {
206 blockingGetProfile(retrieveProfile(recipientId
, SignalServiceProfile
.RequestType
.PROFILE
));
207 } catch (IOException e
) {
208 logger
.warn("Failed to retrieve profile, ignoring: {}", e
.getMessage());
211 return account
.getProfileStore().getProfile(recipientId
);
214 private boolean isProfileRefreshRequired(final Profile profile
) {
215 if (profile
== null) {
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;
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
);
228 private Profile
decryptProfileAndDownloadAvatar(
229 final RecipientId recipientId
, final ProfileKey profileKey
, final SignalServiceProfile encryptedProfile
231 final var avatarPath
= encryptedProfile
.getAvatar();
232 downloadProfileAvatar(recipientId
, avatarPath
, profileKey
);
234 return ProfileUtils
.decryptProfile(profileKey
, encryptedProfile
);
237 public void downloadProfileAvatar(
238 final RecipientId recipientId
, final String avatarPath
, final ProfileKey profileKey
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
),
246 var builder
= profile
== null ? Profile
.newBuilder() : Profile
.newBuilder(profile
);
247 account
.getProfileStore().storeProfile(recipientId
, builder
.withAvatarUrlPath(avatarPath
).build());
251 private ProfileAndCredential
blockingGetProfile(Single
<ProfileAndCredential
> profile
) throws IOException
{
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();
260 throw new IOException(e
);
265 private Single
<ProfileAndCredential
> retrieveProfile(
266 RecipientId recipientId
, SignalServiceProfile
.RequestType requestType
268 var unidentifiedAccess
= getUnidentifiedAccess(recipientId
);
269 var profileKey
= Optional
.ofNullable(account
.getProfileStore().getProfileKey(recipientId
));
271 logger
.trace("Retrieving profile for {} {}",
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();
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
);
286 final var profile
= account
.getProfileStore().getProfile(recipientId
);
288 Profile newProfile
= null;
289 if (profileKey
.isPresent()) {
290 logger
.trace("Decrypting profile");
291 newProfile
= decryptProfileAndDownloadAvatar(recipientId
, profileKey
.get(), encryptedProfile
);
294 if (newProfile
== null) {
296 profile
== null ? Profile
.newBuilder() : Profile
.newBuilder(profile
)
297 ).withLastUpdateTimestamp(System
.currentTimeMillis())
298 .withUnidentifiedAccessMode(ProfileUtils
.getUnidentifiedAccessMode(encryptedProfile
, null))
299 .withCapabilities(ProfileUtils
.getCapabilities(encryptedProfile
))
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());
312 logger
.trace("Storing profile");
313 account
.getProfileStore().storeProfile(recipientId
, newProfile
);
315 logger
.trace("Done handling retrieved profile");
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())
326 account
.getProfileStore().storeProfile(recipientId
, newProfile
);
330 private Single
<ProfileAndCredential
> retrieveProfile(
331 SignalServiceAddress address
,
332 Optional
<ProfileKey
> profileKey
,
333 Optional
<UnidentifiedAccess
> unidentifiedAccess
,
334 SignalServiceProfile
.RequestType requestType
336 final var profileService
= dependencies
.getProfileService();
337 final var locale
= Utils
.getDefaultLocale(Locale
.US
);
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");
346 throw pair
.getExecutionError()
347 .or(pair
::getApplicationError
)
348 .orElseThrow(() -> new IOException("Unknown error while retrieving profile"));
353 private void downloadProfileAvatar(
354 RecipientAddress address
, String avatarPath
, ProfileKey profileKey
356 if (avatarPath
== null) {
358 context
.getAvatarStore().deleteProfileAvatar(address
);
359 } catch (IOException e
) {
360 logger
.warn("Failed to delete local profile avatar, ignoring: {}", e
.getMessage());
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());
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
,
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
);
387 Files
.delete(tmpFile
.toPath());
388 } catch (IOException e
) {
389 logger
.warn("Failed to delete received profile avatar temp file “{}”, ignoring: {}",
396 private Optional
<UnidentifiedAccess
> getUnidentifiedAccess(RecipientId recipientId
) {
397 var unidentifiedAccess
= context
.getUnidentifiedAccessHelper().getAccessFor(recipientId
, true);
399 if (unidentifiedAccess
.isPresent()) {
400 return unidentifiedAccess
.get().getTargetUnidentifiedAccess();
403 return Optional
.empty();