]> nmode's Git Repositories - signal-cli/blobdiff - lib/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
Move more profile functionality to ProfileHelper
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / helper / ProfileHelper.java
index c3c74b0b8df7f5027466899da6dd6686fe185315..ac75a5730d5272f31b5653d0bbb66cca9af2f83e 100644 (file)
@@ -1,7 +1,20 @@
 package org.asamk.signal.manager.helper;
 
+import org.asamk.signal.manager.AvatarStore;
+import org.asamk.signal.manager.SignalDependencies;
+import org.asamk.signal.manager.config.ServiceConfig;
+import org.asamk.signal.manager.storage.SignalAccount;
+import org.asamk.signal.manager.storage.recipients.Profile;
 import org.asamk.signal.manager.storage.recipients.RecipientId;
+import org.asamk.signal.manager.util.IOUtils;
+import org.asamk.signal.manager.util.ProfileUtils;
+import org.asamk.signal.manager.util.Utils;
 import org.signal.zkgroup.profiles.ProfileKey;
+import org.signal.zkgroup.profiles.ProfileKeyCredential;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.whispersystems.libsignal.IdentityKey;
+import org.whispersystems.libsignal.InvalidKeyException;
 import org.whispersystems.libsignal.util.guava.Optional;
 import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
 import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
@@ -12,29 +25,43 @@ import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
 import org.whispersystems.signalservice.api.services.ProfileService;
 import org.whispersystems.signalservice.internal.ServiceResponse;
 
+import java.io.File;
 import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.util.Base64;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.Set;
 
 import io.reactivex.rxjava3.core.Single;
 
 public final class ProfileHelper {
 
-    private final ProfileKeyProvider profileKeyProvider;
+    private final static Logger logger = LoggerFactory.getLogger(ProfileHelper.class);
 
+    private final SignalAccount account;
+    private final SignalDependencies dependencies;
+    private final AvatarStore avatarStore;
+    private final ProfileKeyProvider profileKeyProvider;
     private final UnidentifiedAccessProvider unidentifiedAccessProvider;
-
     private final ProfileServiceProvider profileServiceProvider;
-
     private final MessageReceiverProvider messageReceiverProvider;
-
     private final SignalServiceAddressResolver addressResolver;
 
     public ProfileHelper(
+            final SignalAccount account,
+            final SignalDependencies dependencies,
+            final AvatarStore avatarStore,
             final ProfileKeyProvider profileKeyProvider,
             final UnidentifiedAccessProvider unidentifiedAccessProvider,
             final ProfileServiceProvider profileServiceProvider,
             final MessageReceiverProvider messageReceiverProvider,
             final SignalServiceAddressResolver addressResolver
     ) {
+        this.account = account;
+        this.dependencies = dependencies;
+        this.avatarStore = avatarStore;
         this.profileKeyProvider = profileKeyProvider;
         this.unidentifiedAccessProvider = unidentifiedAccessProvider;
         this.profileServiceProvider = profileServiceProvider;
@@ -42,7 +69,193 @@ public final class ProfileHelper {
         this.addressResolver = addressResolver;
     }
 
-    public ProfileAndCredential retrieveProfileSync(
+    public Profile getRecipientProfile(RecipientId recipientId) {
+        return getRecipientProfile(recipientId, false);
+    }
+
+    public void refreshRecipientProfile(RecipientId recipientId) {
+        getRecipientProfile(recipientId, true);
+    }
+
+    public ProfileKeyCredential getRecipientProfileKeyCredential(RecipientId recipientId) {
+        var profileKeyCredential = account.getProfileStore().getProfileKeyCredential(recipientId);
+        if (profileKeyCredential != null) {
+            return profileKeyCredential;
+        }
+
+        ProfileAndCredential profileAndCredential;
+        try {
+            profileAndCredential = retrieveProfileAndCredential(recipientId,
+                    SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL);
+        } catch (IOException e) {
+            logger.warn("Failed to retrieve profile key credential, ignoring: {}", e.getMessage());
+            return null;
+        }
+
+        profileKeyCredential = profileAndCredential.getProfileKeyCredential().orNull();
+        account.getProfileStore().storeProfileKeyCredential(recipientId, profileKeyCredential);
+
+        var profileKey = account.getProfileStore().getProfileKey(recipientId);
+        if (profileKey != null) {
+            final var profile = decryptProfileAndDownloadAvatar(recipientId,
+                    profileKey,
+                    profileAndCredential.getProfile());
+            account.getProfileStore().storeProfile(recipientId, profile);
+        }
+
+        return profileKeyCredential;
+    }
+
+    /**
+     * @param givenName  if null, the previous givenName will be kept
+     * @param familyName if null, the previous familyName will be kept
+     * @param about      if null, the previous about text will be kept
+     * @param aboutEmoji if null, the previous about emoji will be kept
+     * @param avatar     if avatar is null the image from the local avatar store is used (if present),
+     */
+    public void setProfile(
+            String givenName, final String familyName, String about, String aboutEmoji, Optional<File> avatar
+    ) throws IOException {
+        var profile = getRecipientProfile(account.getSelfRecipientId());
+        var builder = profile == null ? Profile.newBuilder() : Profile.newBuilder(profile);
+        if (givenName != null) {
+            builder.withGivenName(givenName);
+        }
+        if (familyName != null) {
+            builder.withFamilyName(familyName);
+        }
+        if (about != null) {
+            builder.withAbout(about);
+        }
+        if (aboutEmoji != null) {
+            builder.withAboutEmoji(aboutEmoji);
+        }
+        var newProfile = builder.build();
+
+        try (final var streamDetails = avatar == null
+                ? avatarStore.retrieveProfileAvatar(account.getSelfAddress())
+                : avatar.isPresent() ? Utils.createStreamDetailsFromFile(avatar.get()) : null) {
+            dependencies.getAccountManager()
+                    .setVersionedProfile(account.getUuid(),
+                            account.getProfileKey(),
+                            newProfile.getInternalServiceName(),
+                            newProfile.getAbout() == null ? "" : newProfile.getAbout(),
+                            newProfile.getAboutEmoji() == null ? "" : newProfile.getAboutEmoji(),
+                            Optional.absent(),
+                            streamDetails);
+        }
+
+        if (avatar != null) {
+            if (avatar.isPresent()) {
+                avatarStore.storeProfileAvatar(account.getSelfAddress(),
+                        outputStream -> IOUtils.copyFileToStream(avatar.get(), outputStream));
+            } else {
+                avatarStore.deleteProfileAvatar(account.getSelfAddress());
+            }
+        }
+        account.getProfileStore().storeProfile(account.getSelfRecipientId(), newProfile);
+    }
+
+    private final Set<RecipientId> pendingProfileRequest = new HashSet<>();
+
+    private Profile getRecipientProfile(RecipientId recipientId, boolean force) {
+        var profile = account.getProfileStore().getProfile(recipientId);
+
+        var now = System.currentTimeMillis();
+        // Profiles are cached for 24h before retrieving them again, unless forced
+        if (!force && profile != null && now - profile.getLastUpdateTimestamp() < 24 * 60 * 60 * 1000) {
+            return profile;
+        }
+
+        synchronized (pendingProfileRequest) {
+            if (pendingProfileRequest.contains(recipientId)) {
+                return profile;
+            }
+            pendingProfileRequest.add(recipientId);
+        }
+        final SignalServiceProfile encryptedProfile;
+        try {
+            encryptedProfile = retrieveEncryptedProfile(recipientId);
+        } finally {
+            synchronized (pendingProfileRequest) {
+                pendingProfileRequest.remove(recipientId);
+            }
+        }
+        if (encryptedProfile == null) {
+            return null;
+        }
+
+        profile = decryptProfileIfKeyKnown(recipientId, encryptedProfile);
+        account.getProfileStore().storeProfile(recipientId, profile);
+
+        return profile;
+    }
+
+    private Profile decryptProfileIfKeyKnown(
+            final RecipientId recipientId, final SignalServiceProfile encryptedProfile
+    ) {
+        var profileKey = account.getProfileStore().getProfileKey(recipientId);
+        if (profileKey == null) {
+            return new Profile(System.currentTimeMillis(),
+                    null,
+                    null,
+                    null,
+                    null,
+                    ProfileUtils.getUnidentifiedAccessMode(encryptedProfile, null),
+                    ProfileUtils.getCapabilities(encryptedProfile));
+        }
+
+        return decryptProfileAndDownloadAvatar(recipientId, profileKey, encryptedProfile);
+    }
+
+    private SignalServiceProfile retrieveEncryptedProfile(RecipientId recipientId) {
+        try {
+            return retrieveProfileAndCredential(recipientId, SignalServiceProfile.RequestType.PROFILE).getProfile();
+        } catch (IOException e) {
+            logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private SignalServiceProfile retrieveProfileSync(String username) throws IOException {
+        return messageReceiverProvider.getMessageReceiver().retrieveProfileByUsername(username, Optional.absent());
+    }
+
+    private ProfileAndCredential retrieveProfileAndCredential(
+            final RecipientId recipientId, final SignalServiceProfile.RequestType requestType
+    ) throws IOException {
+        final var profileAndCredential = retrieveProfileSync(recipientId, requestType);
+        final var profile = profileAndCredential.getProfile();
+
+        try {
+            var newIdentity = account.getIdentityKeyStore()
+                    .saveIdentity(recipientId,
+                            new IdentityKey(Base64.getDecoder().decode(profile.getIdentityKey())),
+                            new Date());
+
+            if (newIdentity) {
+                account.getSessionStore().archiveSessions(recipientId);
+            }
+        } catch (InvalidKeyException ignored) {
+            logger.warn("Got invalid identity key in profile for {}",
+                    addressResolver.resolveSignalServiceAddress(recipientId).getIdentifier());
+        }
+        return profileAndCredential;
+    }
+
+    private Profile decryptProfileAndDownloadAvatar(
+            final RecipientId recipientId, final ProfileKey profileKey, final SignalServiceProfile encryptedProfile
+    ) {
+        if (encryptedProfile.getAvatar() != null) {
+            downloadProfileAvatar(addressResolver.resolveSignalServiceAddress(recipientId),
+                    encryptedProfile.getAvatar(),
+                    profileKey);
+        }
+
+        return ProfileUtils.decryptProfile(profileKey, encryptedProfile);
+    }
+
+    private ProfileAndCredential retrieveProfileSync(
             RecipientId recipientId, SignalServiceProfile.RequestType requestType
     ) throws IOException {
         try {
@@ -58,11 +271,7 @@ public final class ProfileHelper {
         }
     }
 
-    public SignalServiceProfile retrieveProfileSync(String username) throws IOException {
-        return messageReceiverProvider.getMessageReceiver().retrieveProfileByUsername(username, Optional.absent());
-    }
-
-    public Single<ProfileAndCredential> retrieveProfile(
+    private Single<ProfileAndCredential> retrieveProfile(
             RecipientId recipientId, SignalServiceProfile.RequestType requestType
     ) throws IOException {
         var unidentifiedAccess = getUnidentifiedAccess(recipientId);
@@ -106,6 +315,42 @@ public final class ProfileHelper {
         });
     }
 
+    private void downloadProfileAvatar(
+            SignalServiceAddress address, String avatarPath, ProfileKey profileKey
+    ) {
+        try {
+            avatarStore.storeProfileAvatar(address,
+                    outputStream -> retrieveProfileAvatar(avatarPath, profileKey, outputStream));
+        } catch (Throwable e) {
+            if (e instanceof AssertionError && e.getCause() instanceof InterruptedException) {
+                Thread.currentThread().interrupt();
+            }
+            logger.warn("Failed to download profile avatar, ignoring: {}", e.getMessage());
+        }
+    }
+
+    private void retrieveProfileAvatar(
+            String avatarPath, ProfileKey profileKey, OutputStream outputStream
+    ) throws IOException {
+        var tmpFile = IOUtils.createTempFile();
+        try (var input = dependencies.getMessageReceiver()
+                .retrieveProfileAvatar(avatarPath,
+                        tmpFile,
+                        profileKey,
+                        ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) {
+            // Use larger buffer size to prevent AssertionError: Need: 12272 but only have: 8192 ...
+            IOUtils.copyStream(input, outputStream, (int) ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE);
+        } finally {
+            try {
+                Files.delete(tmpFile.toPath());
+            } catch (IOException e) {
+                logger.warn("Failed to delete received profile avatar temp file “{}”, ignoring: {}",
+                        tmpFile,
+                        e.getMessage());
+            }
+        }
+    }
+
     private Optional<UnidentifiedAccess> getUnidentifiedAccess(RecipientId recipientId) {
         var unidentifiedAccess = unidentifiedAccessProvider.getAccessFor(recipientId);