From 4c9cdf3b8fcaca0dd4101353b188c697d01f2ffc Mon Sep 17 00:00:00 2001 From: andrew-signal Date: Thu, 12 Feb 2026 14:06:08 -0500 Subject: [PATCH] Refactor RefreshOwnProfileJob to Kotlin. --- .../securesms/jobs/RefreshOwnProfileJob.java | 507 ------------------ .../securesms/jobs/RefreshOwnProfileJob.kt | 483 +++++++++++++++++ 2 files changed, 483 insertions(+), 507 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java deleted file mode 100644 index 81c7070a02..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java +++ /dev/null @@ -1,507 +0,0 @@ -package org.thoughtcrime.securesms.jobs; - -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.signal.core.util.Base64; -import org.signal.core.util.logging.Log; -import org.signal.libsignal.usernames.BaseUsernameException; -import org.signal.libsignal.usernames.Username; -import org.signal.libsignal.zkgroup.VerificationFailedException; -import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential; -import org.signal.libsignal.zkgroup.profiles.ProfileKey; -import org.thoughtcrime.securesms.badges.BadgeRepository; -import org.thoughtcrime.securesms.badges.Badges; -import org.thoughtcrime.securesms.badges.models.Badge; -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository; -import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; -import org.thoughtcrime.securesms.database.RecipientTable; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord; -import org.thoughtcrime.securesms.dependencies.AppDependencies; -import org.thoughtcrime.securesms.jobmanager.Job; -import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.net.SignalNetwork; -import org.thoughtcrime.securesms.profiles.ProfileName; -import org.thoughtcrime.securesms.profiles.manage.UsernameRepository; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.ProfileUtil; -import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.signal.core.util.Util; -import org.whispersystems.signalservice.api.NetworkResultUtil; -import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException; -import org.whispersystems.signalservice.api.crypto.ProfileCipher; -import org.whispersystems.signalservice.api.profiles.ProfileAndCredential; -import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; -import org.whispersystems.signalservice.api.push.UsernameLinkComponents; -import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; -import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription; -import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil; -import org.whispersystems.signalservice.internal.ServiceResponse; -import org.whispersystems.signalservice.internal.push.WhoAmIResponse; - -import java.io.IOException; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Collectors; - - -/** - * Refreshes the profile of the local user. Different from {@link RetrieveProfileJob} in that we - * have to sometimes look at/set different data stores, and we will *always* do the fetch regardless - * of caching. - */ -public class RefreshOwnProfileJob extends BaseJob { - - public static final String KEY = "RefreshOwnProfileJob"; - - private static final String TAG = Log.tag(RefreshOwnProfileJob.class); - - private static final String SUBSCRIPTION_QUEUE = ProfileUploadJob.QUEUE + "_Subscription"; - private static final String BOOST_QUEUE = ProfileUploadJob.QUEUE + "_Boost"; - - public RefreshOwnProfileJob() { - this(ProfileUploadJob.QUEUE); - } - - private RefreshOwnProfileJob(@NonNull String queue) { - this(new Parameters.Builder() - .addConstraint(NetworkConstraint.KEY) - .setQueue(queue) - .setMaxInstancesForFactory(1) - .setMaxAttempts(10) - .build()); - } - - public static @NonNull RefreshOwnProfileJob forSubscription() { - return new RefreshOwnProfileJob(SUBSCRIPTION_QUEUE); - } - - public static @NonNull RefreshOwnProfileJob forBoost() { - return new RefreshOwnProfileJob(BOOST_QUEUE); - } - - - private RefreshOwnProfileJob(@NonNull Parameters parameters) { - super(parameters); - } - - @Override - public @Nullable byte[] serialize() { - return null; - } - - @Override - public @NonNull String getFactoryKey() { - return KEY; - } - - @Override - protected void onRun() throws Exception { - if (!SignalStore.account().isRegistered() || TextUtils.isEmpty(SignalStore.account().getE164())) { - Log.w(TAG, "Not yet registered!"); - return; - } - - if ((SignalStore.svr().hasPin() || SignalStore.account().restoredAccountEntropyPool()) && !SignalStore.svr().hasOptedOut() && SignalStore.storageService().getLastSyncTime() == 0) { - Log.i(TAG, "Registered with PIN or AEP but haven't completed storage sync yet."); - return; - } - - if (!SignalStore.registration().hasUploadedProfile() && SignalStore.account().isPrimaryDevice()) { - Log.i(TAG, "Registered but haven't uploaded profile yet."); - return; - } - - Recipient self = Recipient.self(); - - ProfileAndCredential profileAndCredential; - try { - profileAndCredential = ProfileUtil.retrieveProfileSync(context, self, getRequestType(self), false); - } catch (IllegalStateException e) { - Log.w(TAG, "Unexpected exception result from profile fetch. Skipping."); - return; - } - - - SignalServiceProfile profile = profileAndCredential.getProfile(); - - if (Util.isEmpty(profile.getName()) && - Util.isEmpty(profile.getAvatar()) && - Util.isEmpty(profile.getAbout()) && - Util.isEmpty(profile.getAboutEmoji())) - { - Log.w(TAG, "The profile we retrieved was empty! Ignoring it."); - - if (!self.getProfileName().isEmpty()) { - Log.w(TAG, "We have a name locally. Scheduling a profile upload."); - AppDependencies.getJobManager().add(new ProfileUploadJob()); - } else { - Log.w(TAG, "We don't have a name locally, either!"); - } - - return; - } - - setProfileName(profile.getName()); - setProfileAbout(profile.getAbout(), profile.getAboutEmoji()); - setProfileAvatar(profile.getAvatar()); - setProfileCapabilities(profile.getCapabilities()); - setProfileBadges(profile.getBadges()); - ensureUnidentifiedAccessCorrect(profile.getUnidentifiedAccess(), profile.isUnrestrictedUnidentifiedAccess()); - ensurePhoneNumberSharingIsCorrect(profile.getPhoneNumberSharing()); - - profileAndCredential.getExpiringProfileKeyCredential() - .ifPresent(expiringProfileKeyCredential -> setExpiringProfileKeyCredential(self, ProfileKeyUtil.getSelfProfileKey(), expiringProfileKeyCredential)); - - SignalStore.registration().setHasDownloadedProfile(true); - - StoryOnboardingDownloadJob.Companion.enqueueIfNeeded(); - - checkUsernameIsInSync(); - } - - private void setExpiringProfileKeyCredential(@NonNull Recipient recipient, - @NonNull ProfileKey recipientProfileKey, - @NonNull ExpiringProfileKeyCredential credential) - { - RecipientTable recipientTable = SignalDatabase.recipients(); - recipientTable.setProfileKeyCredential(recipient.getId(), recipientProfileKey, credential); - } - - private static SignalServiceProfile.RequestType getRequestType(@NonNull Recipient recipient) { - return ExpiringProfileCredentialUtil.isValid(recipient.getExpiringProfileKeyCredential()) ? SignalServiceProfile.RequestType.PROFILE - : SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL; - } - - @Override - protected boolean onShouldRetry(@NonNull Exception e) { - return e instanceof PushNetworkException; - } - - @Override - public void onFailure() { } - - private void setProfileName(@Nullable String encryptedName) { - try { - ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); - String plaintextName = ProfileUtil.decryptString(profileKey, encryptedName); - ProfileName profileName = ProfileName.fromSerialized(plaintextName); - - if (!profileName.isEmpty()) { - Log.d(TAG, "Saving non-empty name."); - SignalDatabase.recipients().setProfileName(Recipient.self().getId(), profileName); - } else { - Log.w(TAG, "Ignoring empty name."); - } - - } catch (InvalidCiphertextException | IOException e) { - Log.w(TAG, e); - } - } - - private void setProfileAbout(@Nullable String encryptedAbout, @Nullable String encryptedEmoji) { - try { - ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); - String plaintextAbout = ProfileUtil.decryptString(profileKey, encryptedAbout); - String plaintextEmoji = ProfileUtil.decryptString(profileKey, encryptedEmoji); - - Log.d(TAG, "Saving " + (!Util.isEmpty(plaintextAbout) ? "non-" : "") + "empty about."); - Log.d(TAG, "Saving " + (!Util.isEmpty(plaintextEmoji) ? "non-" : "") + "empty emoji."); - - SignalDatabase.recipients().setAbout(Recipient.self().getId(), plaintextAbout, plaintextEmoji); - } catch (InvalidCiphertextException | IOException e) { - Log.w(TAG, e); - } - } - - private static void setProfileAvatar(@Nullable String avatar) { - Log.d(TAG, "Saving " + (!Util.isEmpty(avatar) ? "non-" : "") + "empty avatar."); - AppDependencies.getJobManager().add(new RetrieveProfileAvatarJob(Recipient.self(), avatar)); - } - - private void setProfileCapabilities(@Nullable SignalServiceProfile.Capabilities capabilities) { - if (capabilities == null) { - return; - } - - Recipient selfSnapshot = Recipient.self(); - - SignalDatabase.recipients().setCapabilities(Recipient.self().getId(), capabilities); - } - - private void ensureUnidentifiedAccessCorrect(@Nullable String unidentifiedAccessVerifier, boolean universalUnidentifiedAccess) { - if (unidentifiedAccessVerifier == null) { - Log.w(TAG, "No unidentified access is set remotely! Refreshing attributes."); - AppDependencies.getJobManager().add(new RefreshAttributesJob()); - return; - } - - if (TextSecurePreferences.isUniversalUnidentifiedAccess(context) != universalUnidentifiedAccess) { - Log.w(TAG, "The universal access flag doesn't match our local value (local: " + TextSecurePreferences.isUniversalUnidentifiedAccess(context) + ", remote: " + universalUnidentifiedAccess + ")! Refreshing attributes."); - AppDependencies.getJobManager().add(new RefreshAttributesJob()); - return; - } - - ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); - ProfileCipher cipher = new ProfileCipher(profileKey); - - boolean verified; - try { - verified = cipher.verifyUnidentifiedAccess(Base64.decode(unidentifiedAccessVerifier)); - } catch (IOException e) { - Log.w(TAG, "Failed to decode unidentified access!", e); - verified = false; - } - - if (!verified) { - Log.w(TAG, "Unidentified access failed to verify! Refreshing attributes."); - AppDependencies.getJobManager().add(new RefreshAttributesJob()); - } - } - - /** - * Checks to make sure that our phone number sharing setting matches what's on our profile. If there's a mismatch, we first sync with storage service - * (to limit race conditions between devices) and then upload our profile. - */ - private void ensurePhoneNumberSharingIsCorrect(@Nullable String phoneNumberSharingCiphertext) { - if (phoneNumberSharingCiphertext == null) { - Log.w(TAG, "No phone number sharing is set remotely! Syncing with storage service, then uploading our profile."); - syncWithStorageServiceThenUploadProfile(); - return; - } - - ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); - ProfileCipher cipher = new ProfileCipher(profileKey); - - try { - RecipientTable.PhoneNumberSharingState remotePhoneNumberSharing = cipher.decryptBoolean(Base64.decode(phoneNumberSharingCiphertext)) - .map(value -> value ? RecipientTable.PhoneNumberSharingState.ENABLED : RecipientTable.PhoneNumberSharingState.DISABLED) - .orElse(RecipientTable.PhoneNumberSharingState.UNKNOWN); - - if (remotePhoneNumberSharing == RecipientTable.PhoneNumberSharingState.UNKNOWN || remotePhoneNumberSharing.getEnabled() != SignalStore.phoneNumberPrivacy().isPhoneNumberSharingEnabled()) { - Log.w(TAG, "Phone number sharing setting did not match! Syncing with storage service, then uploading our profile."); - syncWithStorageServiceThenUploadProfile(); - } - } catch (IOException e) { - Log.w(TAG, "Failed to decode phone number sharing! Syncing with storage service, then uploading our profile.", e); - syncWithStorageServiceThenUploadProfile(); - } catch (InvalidCiphertextException e) { - Log.w(TAG, "Failed to decrypt phone number sharing! Syncing with storage service, then uploading our profile.", e); - syncWithStorageServiceThenUploadProfile(); - } - } - - private void syncWithStorageServiceThenUploadProfile() { - AppDependencies.getJobManager() - .startChain(StorageSyncJob.forRemoteChange()) - .then(new ProfileUploadJob()) - .enqueue(); - } - - private static void checkUsernameIsInSync() { - boolean validated = false; - - try { - String localUsername = SignalStore.account().getUsername(); - - WhoAmIResponse whoAmIResponse = AppDependencies.getSignalServiceAccountManager().getWhoAmI(); - String remoteUsernameHash = whoAmIResponse.getUsernameHash(); - String localUsernameHash = localUsername != null ? Base64.encodeUrlSafeWithoutPadding(new Username(localUsername).getHash()) : null; - - if (TextUtils.isEmpty(localUsernameHash) && TextUtils.isEmpty(remoteUsernameHash)) { - Log.d(TAG, "Local and remote username hash are both empty. Considering validated."); - UsernameRepository.onUsernameConsistencyValidated(); - } else if (!Objects.equals(localUsernameHash, remoteUsernameHash)) { - Log.w(TAG, "Local username hash does not match server username hash. Local hash: " + (TextUtils.isEmpty(localUsername) ? "empty" : "present") + ", Remote hash: " + (TextUtils.isEmpty(remoteUsernameHash) ? "empty" : "present")); - UsernameRepository.onUsernameMismatchDetected(); - return; - } else { - Log.d(TAG, "Username validated."); - } - } catch (IOException e) { - Log.w(TAG, "Failed perform synchronization check during username phase.", e); - } catch (BaseUsernameException e) { - Log.w(TAG, "Our local username data is invalid!", e); - UsernameRepository.onUsernameMismatchDetected(); - return; - } - - try { - UsernameLinkComponents localUsernameLink = SignalStore.account().getUsernameLink(); - - if (localUsernameLink != null) { - byte[] remoteEncryptedUsername = NetworkResultUtil.toBasicLegacy(SignalNetwork.username().getEncryptedUsernameFromLinkServerId(localUsernameLink.getServerId())); - Username.UsernameLink combinedLink = new Username.UsernameLink(localUsernameLink.getEntropy(), remoteEncryptedUsername); - Username remoteUsername = Username.fromLink(combinedLink); - - if (!remoteUsername.getUsername().equals(SignalStore.account().getUsername())) { - Log.w(TAG, "The remote username decrypted ok, but the decrypted username did not match our local username!"); - UsernameRepository.onUsernameLinkMismatchDetected(); - } else { - Log.d(TAG, "Username link validated."); - } - - validated = true; - } - } catch (IOException e) { - Log.w(TAG, "Failed perform synchronization check during the username link phase.", e); - } catch (BaseUsernameException e) { - Log.w(TAG, "Failed to decrypt username link using the remote encrypted username and our local entropy!", e); - UsernameRepository.onUsernameLinkMismatchDetected(); - } - - if (validated) { - UsernameRepository.onUsernameConsistencyValidated(); - } - } - - private void setProfileBadges(@Nullable List badges) throws IOException { - if (badges == null) { - return; - } - - Set localDonorBadgeIds = Recipient.self() - .getBadges() - .stream() - .filter(badge -> badge.getCategory() == Badge.Category.Donor) - .map(Badge::getId) - .collect(Collectors.toSet()); - - Set remoteDonorBadgeIds = badges.stream() - .filter(badge -> Objects.equals(badge.getCategory(), Badge.Category.Donor.getCode())) - .map(SignalServiceProfile.Badge::getId) - .collect(Collectors.toSet()); - - boolean remoteHasSubscriptionBadges = remoteDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isSubscription); - boolean localHasSubscriptionBadges = localDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isSubscription); - boolean remoteHasBoostBadges = remoteDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isBoost); - boolean localHasBoostBadges = localDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isBoost); - boolean remoteHasGiftBadges = remoteDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isGift); - boolean localHasGiftBadges = localDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isGift); - - if (!remoteHasSubscriptionBadges && localHasSubscriptionBadges) { - Badge mostRecentExpiration = Recipient.self() - .getBadges() - .stream() - .filter(badge -> badge.getCategory() == Badge.Category.Donor) - .filter(badge -> isSubscription(badge.getId())) - .max(Comparator.comparingLong(Badge::getExpirationTimestamp)) - .get(); - - Log.d(TAG, "Marking subscription badge as expired, should notify next time the conversation list is open.", true); - SignalStore.inAppPayments().setExpiredBadge(mostRecentExpiration); - - if (!InAppPaymentsRepository.isUserManuallyCancelled(InAppPaymentSubscriberRecord.Type.DONATION)) { - Log.d(TAG, "Detected an unexpected subscription expiry.", true); - InAppPaymentSubscriberRecord subscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION); - - boolean isDueToPaymentFailure = false; - if (subscriber != null) { - ServiceResponse response = AppDependencies.getDonationsService() - .getSubscription(subscriber.getSubscriberId()); - - if (response.getResult().isPresent()) { - ActiveSubscription activeSubscription = response.getResult().get(); - if (activeSubscription.isFailedPayment()) { - Log.d(TAG, "Unexpected expiry due to payment failure.", true); - isDueToPaymentFailure = true; - } - - if (activeSubscription.getChargeFailure() != null) { - Log.d(TAG, "Active payment contains a charge failure: " + activeSubscription.getChargeFailure().getCode(), true); - } - } - - InAppPaymentsRepository.setShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriber, true); - } - - if (!isDueToPaymentFailure) { - Log.d(TAG, "Unexpected expiry due to inactivity.", true); - } - - MultiDeviceSubscriptionSyncRequestJob.enqueue(); - } - } else if (!remoteHasBoostBadges && localHasBoostBadges) { - Badge mostRecentExpiration = Recipient.self() - .getBadges() - .stream() - .filter(badge -> badge.getCategory() == Badge.Category.Donor) - .filter(badge -> isBoost(badge.getId())) - .max(Comparator.comparingLong(Badge::getExpirationTimestamp)) - .get(); - - Log.d(TAG, "Marking boost badge as expired, should notify next time the conversation list is open.", true); - SignalStore.inAppPayments().setExpiredBadge(mostRecentExpiration); - } else { - Badge badge = SignalStore.inAppPayments().getExpiredBadge(); - - if (badge != null && badge.isSubscription() && remoteHasSubscriptionBadges) { - Log.d(TAG, "Remote has subscription badges. Clearing local expired subscription badge.", true); - SignalStore.inAppPayments().setExpiredBadge(null); - } else if (badge != null && badge.isBoost() && remoteHasBoostBadges) { - Log.d(TAG, "Remote has boost badges. Clearing local expired boost badge.", true); - SignalStore.inAppPayments().setExpiredBadge(null); - } - } - - if (!remoteHasGiftBadges && localHasGiftBadges) { - Badge mostRecentExpiration = Recipient.self() - .getBadges() - .stream() - .filter(badge -> badge.getCategory() == Badge.Category.Donor) - .filter(badge -> isGift(badge.getId())) - .max(Comparator.comparingLong(Badge::getExpirationTimestamp)) - .get(); - - Log.d(TAG, "Marking gift badge as expired, should notify next time the manage donations screen is open.", true); - SignalStore.inAppPayments().setExpiredGiftBadge(mostRecentExpiration); - } else if (remoteHasGiftBadges) { - Log.d(TAG, "We have remote gift badges. Clearing local expired gift badge.", true); - SignalStore.inAppPayments().setExpiredGiftBadge(null); - } - - boolean userHasVisibleBadges = badges.stream().anyMatch(SignalServiceProfile.Badge::isVisible); - boolean userHasInvisibleBadges = badges.stream().anyMatch(b -> !b.isVisible()); - - List appBadges = badges.stream().map(Badges::fromServiceBadge).collect(Collectors.toList()); - - if (userHasVisibleBadges && userHasInvisibleBadges) { - boolean displayBadgesOnProfile = SignalStore.inAppPayments().getDisplayBadgesOnProfile(); - Log.d(TAG, "Detected mixed visibility of badges. Telling the server to mark them all " + - (displayBadgesOnProfile ? "" : "not") + - " visible.", true); - - BadgeRepository badgeRepository = new BadgeRepository(context); - List updatedBadges = badgeRepository.setVisibilityForAllBadgesSync(displayBadgesOnProfile, appBadges); - SignalDatabase.recipients().setBadges(Recipient.self().getId(), updatedBadges); - } else { - SignalDatabase.recipients().setBadges(Recipient.self().getId(), appBadges); - } - } - - private static boolean isSubscription(String badgeId) { - return !isBoost(badgeId) && !isGift(badgeId); - } - - private static boolean isBoost(String badgeId) { - return Objects.equals(badgeId, Badge.BOOST_BADGE_ID); - } - - private static boolean isGift(String badgeId) { - return Objects.equals(badgeId, Badge.GIFT_BADGE_ID); - } - - public static final class Factory implements Job.Factory { - - @Override - public @NonNull RefreshOwnProfileJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) { - return new RefreshOwnProfileJob(parameters); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.kt new file mode 100644 index 0000000000..2bf3ca2121 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.kt @@ -0,0 +1,483 @@ +package org.thoughtcrime.securesms.jobs + +import android.text.TextUtils +import org.signal.core.util.Base64 +import org.signal.core.util.Util +import org.signal.core.util.logging.Log +import org.signal.libsignal.usernames.BaseUsernameException +import org.signal.libsignal.usernames.Username +import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential +import org.signal.libsignal.zkgroup.profiles.ProfileKey +import org.thoughtcrime.securesms.badges.BadgeRepository +import org.thoughtcrime.securesms.badges.Badges +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil +import org.thoughtcrime.securesms.database.RecipientTable.PhoneNumberSharingState +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.net.SignalNetwork +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.profiles.manage.UsernameRepository +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.ProfileUtil +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.signalservice.api.NetworkResultUtil +import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException +import org.whispersystems.signalservice.api.crypto.ProfileCipher +import org.whispersystems.signalservice.api.profiles.ProfileAndCredential +import org.whispersystems.signalservice.api.profiles.SignalServiceProfile +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException +import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil +import java.io.IOException + +/** + * Refreshes the profile of the local user. Different from [RetrieveProfileJob] in that we + * have to sometimes look at/set different data stores, and we will *always* do the fetch regardless + * of caching. + */ +class RefreshOwnProfileJob private constructor(parameters: Parameters) : BaseJob(parameters) { + + companion object { + const val KEY: String = "RefreshOwnProfileJob" + + private val TAG = Log.tag(RefreshOwnProfileJob::class.java) + + private val SUBSCRIPTION_QUEUE = ProfileUploadJob.QUEUE + "_Subscription" + private val BOOST_QUEUE = ProfileUploadJob.QUEUE + "_Boost" + + fun forSubscription(): RefreshOwnProfileJob { + return RefreshOwnProfileJob(SUBSCRIPTION_QUEUE) + } + + fun forBoost(): RefreshOwnProfileJob { + return RefreshOwnProfileJob(BOOST_QUEUE) + } + } + + constructor() : this(ProfileUploadJob.QUEUE) + + private constructor(queue: String) : this( + Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue(queue) + .setMaxInstancesForFactory(1) + .setMaxAttempts(10) + .build() + ) + + override fun serialize(): ByteArray? { + return null + } + + override fun getFactoryKey(): String { + return KEY + } + + @Throws(Exception::class) + override fun onRun() { + if (!SignalStore.account.isRegistered || SignalStore.account.e164.isNullOrEmpty()) { + Log.w(TAG, "Not yet registered!") + return + } + + if ((SignalStore.svr.hasPin() || SignalStore.account.restoredAccountEntropyPool) && !SignalStore.svr.hasOptedOut() && SignalStore.storageService.lastSyncTime == 0L) { + Log.i(TAG, "Registered with PIN or AEP but haven't completed storage sync yet.") + return + } + + if (!SignalStore.registration.hasUploadedProfile && SignalStore.account.isPrimaryDevice) { + Log.i(TAG, "Registered but haven't uploaded profile yet.") + return + } + + val self = Recipient.self() + + val profileAndCredential: ProfileAndCredential + try { + profileAndCredential = ProfileUtil.retrieveProfileSync(context, self, getRequestType(self), false) + } catch (e: IllegalStateException) { + Log.w(TAG, "Unexpected exception result from profile fetch. Skipping.") + return + } + + val profile = profileAndCredential.getProfile() + + if (Util.isEmpty(profile.getName()) && + Util.isEmpty(profile.getAvatar()) && + Util.isEmpty(profile.getAbout()) && + Util.isEmpty(profile.getAboutEmoji()) + ) { + Log.w(TAG, "The profile we retrieved was empty! Ignoring it.") + + if (!self.profileName.isEmpty()) { + Log.w(TAG, "We have a name locally. Scheduling a profile upload.") + AppDependencies.jobManager.add(ProfileUploadJob()) + } else { + Log.w(TAG, "We don't have a name locally, either!") + } + + return + } + + setProfileName(profile.getName()) + setProfileAbout(profile.getAbout(), profile.getAboutEmoji()) + setProfileAvatar(profile.getAvatar()) + setProfileCapabilities(profile.getCapabilities()) + setProfileBadges(profile.getBadges()) + ensureUnidentifiedAccessCorrect(profile.getUnidentifiedAccess(), profile.isUnrestrictedUnidentifiedAccess()) + ensurePhoneNumberSharingIsCorrect(profile.getPhoneNumberSharing()) + + profileAndCredential.getExpiringProfileKeyCredential() + .ifPresent { setExpiringProfileKeyCredential(self, ProfileKeyUtil.getSelfProfileKey(), it) } + + SignalStore.registration.hasDownloadedProfile = true + + StoryOnboardingDownloadJob.enqueueIfNeeded() + + checkUsernameIsInSync() + } + + override fun onShouldRetry(e: Exception): Boolean { + return e is PushNetworkException + } + + override fun onFailure() = Unit + + private fun setExpiringProfileKeyCredential( + recipient: Recipient, + recipientProfileKey: ProfileKey, + credential: ExpiringProfileKeyCredential + ) { + SignalDatabase.recipients.setProfileKeyCredential(recipient.id, recipientProfileKey, credential) + } + + private fun setProfileName(encryptedName: String?) { + try { + val profileKey = ProfileKeyUtil.getSelfProfileKey() + val plaintextName = ProfileUtil.decryptString(profileKey, encryptedName) + val profileName = ProfileName.fromSerialized(plaintextName) + + if (!profileName.isEmpty()) { + Log.d(TAG, "Saving non-empty name.") + SignalDatabase.recipients.setProfileName(Recipient.self().id, profileName) + } else { + Log.w(TAG, "Ignoring empty name.") + } + } catch (e: InvalidCiphertextException) { + Log.w(TAG, e) + } catch (e: IOException) { + Log.w(TAG, e) + } + } + + private fun setProfileAbout(encryptedAbout: String?, encryptedEmoji: String?) { + try { + val profileKey = ProfileKeyUtil.getSelfProfileKey() + val plaintextAbout = ProfileUtil.decryptString(profileKey, encryptedAbout) + val plaintextEmoji = ProfileUtil.decryptString(profileKey, encryptedEmoji) + + Log.d(TAG, "Saving " + (if (!Util.isEmpty(plaintextAbout)) "non-" else "") + "empty about.") + Log.d(TAG, "Saving " + (if (!Util.isEmpty(plaintextEmoji)) "non-" else "") + "empty emoji.") + + SignalDatabase.recipients.setAbout(Recipient.self().id, plaintextAbout, plaintextEmoji) + } catch (e: InvalidCiphertextException) { + Log.w(TAG, e) + } catch (e: IOException) { + Log.w(TAG, e) + } + } + + private fun setProfileAvatar(avatar: String?) { + Log.d(TAG, "Saving " + (if (!Util.isEmpty(avatar)) "non-" else "") + "empty avatar.") + AppDependencies.jobManager.add(RetrieveProfileAvatarJob(Recipient.self(), avatar)) + } + + private fun setProfileCapabilities(capabilities: SignalServiceProfile.Capabilities?) { + if (capabilities == null) { + return + } + + SignalDatabase.recipients.setCapabilities(Recipient.self().id, capabilities) + } + + private fun ensureUnidentifiedAccessCorrect(unidentifiedAccessVerifier: String?, universalUnidentifiedAccess: Boolean) { + if (unidentifiedAccessVerifier == null) { + Log.w(TAG, "No unidentified access is set remotely! Refreshing attributes.") + AppDependencies.jobManager.add(RefreshAttributesJob()) + return + } + + if (TextSecurePreferences.isUniversalUnidentifiedAccess(context) != universalUnidentifiedAccess) { + Log.w(TAG, "The universal access flag doesn't match our local value (local: " + TextSecurePreferences.isUniversalUnidentifiedAccess(context) + ", remote: " + universalUnidentifiedAccess + ")! Refreshing attributes.") + AppDependencies.jobManager.add(RefreshAttributesJob()) + return + } + + val profileKey = ProfileKeyUtil.getSelfProfileKey() + val cipher = ProfileCipher(profileKey) + + val verified = try { + cipher.verifyUnidentifiedAccess(Base64.decode(unidentifiedAccessVerifier)) + } catch (e: IOException) { + Log.w(TAG, "Failed to decode unidentified access!", e) + false + } + + if (!verified) { + Log.w(TAG, "Unidentified access failed to verify! Refreshing attributes.") + AppDependencies.jobManager.add(RefreshAttributesJob()) + } + } + + /** + * Checks to make sure that our phone number sharing setting matches what's on our profile. If there's a mismatch, we first sync with storage service + * (to limit race conditions between devices) and then upload our profile. + */ + private fun ensurePhoneNumberSharingIsCorrect(phoneNumberSharingCiphertext: String?) { + if (phoneNumberSharingCiphertext == null) { + Log.w(TAG, "No phone number sharing is set remotely! Syncing with storage service, then uploading our profile.") + syncWithStorageServiceThenUploadProfile() + return + } + + val profileKey = ProfileKeyUtil.getSelfProfileKey() + val cipher = ProfileCipher(profileKey) + + try { + val remotePhoneNumberSharing = cipher.decryptBoolean(Base64.decode(phoneNumberSharingCiphertext)) + .map { value -> if (value) PhoneNumberSharingState.ENABLED else PhoneNumberSharingState.DISABLED } + .orElse(PhoneNumberSharingState.UNKNOWN) + + if (remotePhoneNumberSharing == PhoneNumberSharingState.UNKNOWN || remotePhoneNumberSharing.enabled != SignalStore.phoneNumberPrivacy.isPhoneNumberSharingEnabled()) { + Log.w(TAG, "Phone number sharing setting did not match! Syncing with storage service, then uploading our profile.") + syncWithStorageServiceThenUploadProfile() + } + } catch (e: IOException) { + Log.w(TAG, "Failed to decode phone number sharing! Syncing with storage service, then uploading our profile.", e) + syncWithStorageServiceThenUploadProfile() + } catch (e: InvalidCiphertextException) { + Log.w(TAG, "Failed to decrypt phone number sharing! Syncing with storage service, then uploading our profile.", e) + syncWithStorageServiceThenUploadProfile() + } + } + + private fun syncWithStorageServiceThenUploadProfile() { + AppDependencies.jobManager + .startChain(StorageSyncJob.forRemoteChange()) + .then(ProfileUploadJob()) + .enqueue() + } + + @Throws(IOException::class) + private fun setProfileBadges(badges: List?) { + if (badges == null) { + return + } + + val localDonorBadgeIds = Recipient.self() + .badges + .filter { it.category == Badge.Category.Donor } + .map { it.id } + .toSet() + + val remoteDonorBadgeIds = badges + .filter { it.getCategory() == Badge.Category.Donor.code } + .map { it.getId() } + .toSet() + + val remoteHasSubscriptionBadges = remoteDonorBadgeIds.any { isSubscription(it) } + val localHasSubscriptionBadges = localDonorBadgeIds.any { isSubscription(it) } + val remoteHasBoostBadges = remoteDonorBadgeIds.any { isBoost(it) } + val localHasBoostBadges = localDonorBadgeIds.any { isBoost(it) } + val remoteHasGiftBadges = remoteDonorBadgeIds.any { isGift(it) } + val localHasGiftBadges = localDonorBadgeIds.any { isGift(it) } + + if (!remoteHasSubscriptionBadges && localHasSubscriptionBadges) { + val mostRecentExpiration = Recipient.self() + .badges + .filter { it.category == Badge.Category.Donor } + .filter { isSubscription(it.id) } + .maxByOrNull { it.expirationTimestamp } + ?: throw NoSuchElementException("No value present") + + Log.d(TAG, "Marking subscription badge as expired, should notify next time the conversation list is open.", true) + SignalStore.inAppPayments.setExpiredBadge(mostRecentExpiration) + + if (!InAppPaymentsRepository.isUserManuallyCancelled(InAppPaymentSubscriberRecord.Type.DONATION)) { + Log.d(TAG, "Detected an unexpected subscription expiry.", true) + val subscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION) + + var isDueToPaymentFailure = false + if (subscriber != null) { + val response = AppDependencies.donationsService + .getSubscription(subscriber.subscriberId) + + if (response.getResult().isPresent()) { + val activeSubscription = response.getResult().get() + if (activeSubscription.isFailedPayment()) { + Log.d(TAG, "Unexpected expiry due to payment failure.", true) + isDueToPaymentFailure = true + } + + if (activeSubscription.getChargeFailure() != null) { + Log.d(TAG, "Active payment contains a charge failure: " + activeSubscription.getChargeFailure().getCode(), true) + } + } + + InAppPaymentsRepository.setShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriber, true) + } + + if (!isDueToPaymentFailure) { + Log.d(TAG, "Unexpected expiry due to inactivity.", true) + } + + MultiDeviceSubscriptionSyncRequestJob.enqueue() + } + } else if (!remoteHasBoostBadges && localHasBoostBadges) { + val mostRecentExpiration = Recipient.self() + .badges + .filter { it.category == Badge.Category.Donor } + .filter { isBoost(it.id) } + .maxByOrNull { it.expirationTimestamp } + ?: throw NoSuchElementException("No value present") + + Log.d(TAG, "Marking boost badge as expired, should notify next time the conversation list is open.", true) + SignalStore.inAppPayments.setExpiredBadge(mostRecentExpiration) + } else { + val badge = SignalStore.inAppPayments.getExpiredBadge() + + if (badge != null && badge.isSubscription() && remoteHasSubscriptionBadges) { + Log.d(TAG, "Remote has subscription badges. Clearing local expired subscription badge.", true) + SignalStore.inAppPayments.setExpiredBadge(null) + } else if (badge != null && badge.isBoost() && remoteHasBoostBadges) { + Log.d(TAG, "Remote has boost badges. Clearing local expired boost badge.", true) + SignalStore.inAppPayments.setExpiredBadge(null) + } + } + + if (!remoteHasGiftBadges && localHasGiftBadges) { + val mostRecentExpiration = Recipient.self() + .badges + .filter { it.category == Badge.Category.Donor } + .filter { isGift(it.id) } + .maxByOrNull { it.expirationTimestamp } + ?: throw NoSuchElementException("No value present") + + Log.d(TAG, "Marking gift badge as expired, should notify next time the manage donations screen is open.", true) + SignalStore.inAppPayments.setExpiredGiftBadge(mostRecentExpiration) + } else if (remoteHasGiftBadges) { + Log.d(TAG, "We have remote gift badges. Clearing local expired gift badge.", true) + SignalStore.inAppPayments.setExpiredGiftBadge(null) + } + + val userHasVisibleBadges = badges.any { it.isVisible() } + val userHasInvisibleBadges = badges.any { !it.isVisible() } + + val appBadges = badges.map { Badges.fromServiceBadge(it) } + + if (userHasVisibleBadges && userHasInvisibleBadges) { + val displayBadgesOnProfile = SignalStore.inAppPayments.getDisplayBadgesOnProfile() + Log.d( + TAG, "Detected mixed visibility of badges. Telling the server to mark them all " + + (if (displayBadgesOnProfile) "" else "not") + + " visible.", true + ) + + val badgeRepository = BadgeRepository(context) + val updatedBadges = badgeRepository.setVisibilityForAllBadgesSync(displayBadgesOnProfile, appBadges) + SignalDatabase.recipients.setBadges(Recipient.self().id, updatedBadges) + } else { + SignalDatabase.recipients.setBadges(Recipient.self().id, appBadges) + } + } + + private fun checkUsernameIsInSync() { + var validated = false + + try { + val localUsername = SignalStore.account.username + + val whoAmIResponse = AppDependencies.signalServiceAccountManager.getWhoAmI() + val remoteUsernameHash = whoAmIResponse.usernameHash + val localUsernameHash = if (localUsername != null) Base64.encodeUrlSafeWithoutPadding(Username(localUsername).getHash()) else null + + if (TextUtils.isEmpty(localUsernameHash) && TextUtils.isEmpty(remoteUsernameHash)) { + Log.d(TAG, "Local and remote username hash are both empty. Considering validated.") + UsernameRepository.onUsernameConsistencyValidated() + } else if (localUsernameHash != remoteUsernameHash) { + Log.w( + TAG, + "Local username hash does not match server username hash. Local hash: " + (if (TextUtils.isEmpty(localUsername)) "empty" else "present") + ", Remote hash: " + (if (TextUtils.isEmpty(remoteUsernameHash)) "empty" else "present") + ) + UsernameRepository.onUsernameMismatchDetected() + return + } else { + Log.d(TAG, "Username validated.") + } + } catch (e: IOException) { + Log.w(TAG, "Failed perform synchronization check during username phase.", e) + } catch (e: BaseUsernameException) { + Log.w(TAG, "Our local username data is invalid!", e) + UsernameRepository.onUsernameMismatchDetected() + return + } + + try { + val localUsernameLink = SignalStore.account.usernameLink + + if (localUsernameLink != null) { + val remoteEncryptedUsername = NetworkResultUtil.toBasicLegacy(SignalNetwork.username.getEncryptedUsernameFromLinkServerId(localUsernameLink.serverId)) + val combinedLink = Username.UsernameLink(localUsernameLink.entropy, remoteEncryptedUsername) + val remoteUsername = Username.fromLink(combinedLink) + + if (remoteUsername.getUsername() != SignalStore.account.username) { + Log.w(TAG, "The remote username decrypted ok, but the decrypted username did not match our local username!") + UsernameRepository.onUsernameLinkMismatchDetected() + } else { + Log.d(TAG, "Username link validated.") + } + + validated = true + } + } catch (e: IOException) { + Log.w(TAG, "Failed perform synchronization check during the username link phase.", e) + } catch (e: BaseUsernameException) { + Log.w(TAG, "Failed to decrypt username link using the remote encrypted username and our local entropy!", e) + UsernameRepository.onUsernameLinkMismatchDetected() + } + + if (validated) { + UsernameRepository.onUsernameConsistencyValidated() + } + } + + private fun getRequestType(recipient: Recipient): SignalServiceProfile.RequestType { + return if (ExpiringProfileCredentialUtil.isValid(recipient.expiringProfileKeyCredential)) + SignalServiceProfile.RequestType.PROFILE + else + SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL + } + + private fun isSubscription(badgeId: String?): Boolean { + return !isBoost(badgeId) && !isGift(badgeId) + } + + private fun isBoost(badgeId: String?): Boolean { + return badgeId == Badge.BOOST_BADGE_ID + } + + private fun isGift(badgeId: String?): Boolean { + return badgeId == Badge.GIFT_BADGE_ID + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): RefreshOwnProfileJob { + return RefreshOwnProfileJob(parameters) + } + } +}