diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index d4c06f116b..3be30e9731 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -157,7 +157,6 @@ public final class JobManagerFactories { put(MultiDeviceVerifiedUpdateJob.KEY, new MultiDeviceVerifiedUpdateJob.Factory()); put(MultiDeviceViewOnceOpenJob.KEY, new MultiDeviceViewOnceOpenJob.Factory()); put(MultiDeviceViewedUpdateJob.KEY, new MultiDeviceViewedUpdateJob.Factory()); - put(NewRegistrationUsernameSyncJob.KEY, new NewRegistrationUsernameSyncJob.Factory()); put(NullMessageSendJob.KEY, new NullMessageSendJob.Factory()); put(OptimizeMessageSearchIndexJob.KEY, new OptimizeMessageSearchIndexJob.Factory()); put(PaymentLedgerUpdateJob.KEY, new PaymentLedgerUpdateJob.Factory()); @@ -179,6 +178,7 @@ public final class JobManagerFactories { put(PushProcessMessageJob.KEY, new PushProcessMessageJob.Factory()); put(ReactionSendJob.KEY, new ReactionSendJob.Factory()); put(RebuildMessageSearchIndexJob.KEY, new RebuildMessageSearchIndexJob.Factory()); + put(ReclaimUsernameAndLinkJob.KEY, new ReclaimUsernameAndLinkJob.Factory()); put(RefreshAttributesJob.KEY, new RefreshAttributesJob.Factory()); put(RefreshCallLinkDetailsJob.KEY, new RefreshCallLinkDetailsJob.Factory()); put(RefreshSvrCredentialsJob.KEY, new RefreshSvrCredentialsJob.Factory()); @@ -309,6 +309,7 @@ public final class JobManagerFactories { put("SmsReceiveJob", new FailingJob.Factory()); put("StoryReadStateMigrationJob", new PassingMigrationJob.Factory()); put("GroupV1MigrationJob", new FailingJob.Factory()); + put("NewRegistrationUsernameSyncJob", new FailingJob.Factory()); }}; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/NewRegistrationUsernameSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/NewRegistrationUsernameSyncJob.kt deleted file mode 100644 index bdc280e68b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/NewRegistrationUsernameSyncJob.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.thoughtcrime.securesms.jobs - -import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.jobmanager.Job -import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint -import java.io.IOException - -/** - * If a user registers and the storage sync service doesn't contain a username, - * then we should delete our username from the server. - */ -class NewRegistrationUsernameSyncJob private constructor(parameters: Parameters) : BaseJob(parameters) { - - companion object { - private val TAG = Log.tag(NewRegistrationUsernameSyncJob::class.java) - - const val KEY = "NewRegistrationUsernameSyncJob" - } - - constructor() : this( - Parameters.Builder() - .setQueue(StorageSyncJob.QUEUE_KEY) - .setMaxInstancesForFactory(1) - .addConstraint(NetworkConstraint.KEY) - .build() - ) - - override fun serialize(): ByteArray? = null - - override fun getFactoryKey(): String = KEY - - override fun onFailure() = Unit - - override fun onRun() { - RefreshOwnProfileJob.checkUsernameIsInSync() - } - - override fun onShouldRetry(e: Exception): Boolean { - return e is IOException - } - - class Factory : Job.Factory { - override fun create(parameters: Parameters, serializedData: ByteArray?): NewRegistrationUsernameSyncJob { - return NewRegistrationUsernameSyncJob(parameters) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ReclaimUsernameAndLinkJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReclaimUsernameAndLinkJob.kt new file mode 100644 index 0000000000..d296c9a56e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReclaimUsernameAndLinkJob.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.profiles.manage.UsernameRepository +import org.thoughtcrime.securesms.util.FeatureFlags +import kotlin.time.Duration.Companion.days + +class ReclaimUsernameAndLinkJob private constructor(parameters: Job.Parameters) : Job(parameters) { + companion object { + const val KEY = "UsernameAndLinkRestoreJob" + + private val TAG = Log.tag(ReclaimUsernameAndLinkJob::class.java) + } + + constructor() : this( + Parameters.Builder() + .setQueue(StorageSyncJob.QUEUE_KEY) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(30.days.inWholeMilliseconds) + .setMaxInstancesForFactory(1) + .build() + ) + + override fun serialize(): ByteArray? = null + + override fun getFactoryKey(): String = KEY + + override fun run(): Result { + return when (UsernameRepository.reclaimUsernameIfNecessary()) { + UsernameRepository.UsernameReclaimResult.SUCCESS -> Result.success() + UsernameRepository.UsernameReclaimResult.PERMANENT_ERROR -> Result.success() + UsernameRepository.UsernameReclaimResult.NETWORK_ERROR -> Result.retry(BackoffUtil.exponentialBackoff(runAttempt + 1, FeatureFlags.getDefaultMaxBackoff())) + } + } + + override fun onFailure() = Unit + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): ReclaimUsernameAndLinkJob { + return ReclaimUsernameAndLinkJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java index 691d3abc02..c1a4723827 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java @@ -296,7 +296,7 @@ public class RefreshOwnProfileJob extends BaseJob { .enqueue(); } - static void checkUsernameIsInSync() { + private static void checkUsernameIsInSync() { boolean validated = false; try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java index abcdca8549..5f9c553843 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java @@ -4,13 +4,17 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.signal.core.util.logging.Log; +import org.signal.libsignal.usernames.BaseUsernameException; +import org.signal.libsignal.usernames.Username; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobmanager.JobTracker; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.AccountValues; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.profiles.manage.UsernameRepository; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.whispersystems.signalservice.api.SignalServiceAccountManager; @@ -111,6 +115,16 @@ public class StorageAccountRestoreJob extends BaseJob { SignalDatabase.getRawDatabase().endTransaction(); } + // We will try to reclaim the username here, as early as possible, but the registration flow also enqueues a username restore job, + // so failing here isn't a huge deal + if (SignalStore.account().getUsername() != null) { + Log.i(TAG, "Attempting to reclaim username..."); + UsernameRepository.UsernameReclaimResult result = UsernameRepository.reclaimUsernameIfNecessary(); + Log.i(TAG, "Username reclaim result: " + result.name()); + } else { + Log.i(TAG, "No username to reclaim."); + } + JobManager jobManager = ApplicationDependencies.getJobManager(); if (accountRecord.getAvatarUrlPath().isPresent()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java index 0645321bae..ec8df5a8a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java @@ -39,6 +39,7 @@ public final class MiscellaneousValues extends SignalStoreValues { private static final String LAST_CONSISTENCY_CHECK_TIME = "misc.last_consistency_check_time"; private static final String SERVER_TIME_OFFSET = "misc.server_time_offset"; private static final String LAST_SERVER_TIME_OFFSET_UPDATE = "misc.last_server_time_offset_update"; + private static final String NEEDS_USERNAME_RESTORE = "misc.needs_username_restore"; MiscellaneousValues(@NonNull KeyValueStore store) { super(store); @@ -47,6 +48,7 @@ public final class MiscellaneousValues extends SignalStoreValues { @Override void onFirstEverAppLaunch() { putLong(MESSAGE_REQUEST_ENABLE_TIME, 0); + putBoolean(NEEDS_USERNAME_RESTORE, true); } @Override @@ -331,4 +333,15 @@ public final class MiscellaneousValues extends SignalStoreValues { public long getLastKnownServerTimeOffsetUpdateTime() { return getLong(LAST_SERVER_TIME_OFFSET_UPDATE, 0); } + + /** + * Whether or not we should attempt to restore the user's username and link. + */ + public boolean needsUsernameRestore() { + return getBoolean(NEEDS_USERNAME_RESTORE, false); + } + + public void setNeedsUsernameRestore(boolean value) { + putBoolean(NEEDS_USERNAME_RESTORE, value); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt index 51d2d6e0b6..c2e877c968 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt @@ -12,7 +12,7 @@ import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.BuildConfig import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobmanager.JobTracker -import org.thoughtcrime.securesms.jobs.NewRegistrationUsernameSyncJob +import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob import org.thoughtcrime.securesms.jobs.RefreshAttributesJob import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob @@ -152,7 +152,7 @@ object SvrRepository { ApplicationDependencies .getJobManager() .startChain(StorageSyncJob()) - .then(NewRegistrationUsernameSyncJob()) + .then(ReclaimUsernameAndLinkJob()) .enqueueAndBlockUntilCompletion(TimeUnit.SECONDS.toMillis(10)) stopwatch.split("contact-restore") diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt index 08dc768b6b..defe771cec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt @@ -121,7 +121,52 @@ object UsernameRepository { } /** - * Deletes the username and username link from the local user's account + * Attempts to reclaim the username that is currently stored on disk if necessary. + * This is intended to be used after registration. + * + * This method call may result in mutating [SignalStore] state. + */ + @WorkerThread + @JvmStatic + fun reclaimUsernameIfNecessary(): UsernameReclaimResult { + if (!SignalStore.misc().needsUsernameRestore()) { + Log.d(TAG, "[reclaimUsernameIfNecessary] No need to restore username. Skipping.") + return UsernameReclaimResult.SUCCESS + } + + val username = SignalStore.account().username + val link = SignalStore.account().usernameLink + + if (username == null || link == null) { + Log.d(TAG, "[reclaimUsernameIfNecessary] No username or link to restore. Skipping.") + SignalStore.misc().setNeedsUsernameRestore(false) + return UsernameReclaimResult.SUCCESS + } + + val result = reclaimUsernameIfNecessaryInternal(Username(username), link) + + when (result) { + UsernameReclaimResult.SUCCESS -> { + Log.i(TAG, "[reclaimUsernameIfNecessary] Successfully reclaimed username and link.") + SignalStore.misc().setNeedsUsernameRestore(false) + } + + UsernameReclaimResult.PERMANENT_ERROR -> { + Log.w(TAG, "[reclaimUsernameIfNecessary] Permanently failed to reclaim username and link. User will see an error.") + SignalStore.account().usernameSyncState = AccountValues.UsernameSyncState.USERNAME_AND_LINK_CORRUPTED + SignalStore.misc().setNeedsUsernameRestore(false) + } + + UsernameReclaimResult.NETWORK_ERROR -> { + Log.w(TAG, "[reclaimUsernameIfNecessary] Hit a transient network error while trying to reclaim username and link.") + } + } + + return result + } + + /** + * Deletes the username from the local user's account */ @JvmStatic fun deleteUsernameAndLink(): Single { @@ -418,10 +463,36 @@ object UsernameRepository { } } + @WorkerThread + @JvmStatic + private fun reclaimUsernameIfNecessaryInternal(username: Username, usernameLinkComponents: UsernameLinkComponents): UsernameReclaimResult { + try { + accountManager.reclaimUsernameAndLink(username, usernameLinkComponents) + } catch (e: UsernameTakenException) { + Log.w(TAG, "[reclaimUsername] Username gone.") + return UsernameReclaimResult.PERMANENT_ERROR + } catch (e: UsernameIsNotReservedException) { + Log.w(TAG, "[reclaimUsername] Username was not reserved.") + return UsernameReclaimResult.PERMANENT_ERROR + } catch (e: BaseUsernameException) { + Log.w(TAG, "[reclaimUsername] Invalid username.") + return UsernameReclaimResult.PERMANENT_ERROR + } catch (e: IOException) { + Log.w(TAG, "[reclaimUsername] Network error.", e) + return UsernameReclaimResult.NETWORK_ERROR + } + + return UsernameReclaimResult.SUCCESS + } + enum class UsernameSetResult { SUCCESS, USERNAME_UNAVAILABLE, USERNAME_INVALID, NETWORK_ERROR, CANDIDATE_GENERATION_ERROR } + enum class UsernameReclaimResult { + SUCCESS, PERMANENT_ERROR, NETWORK_ERROR + } + enum class UsernameDeleteResult { SUCCESS, NETWORK_ERROR } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java index 4869c41bae..1298784b9f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java @@ -8,7 +8,7 @@ import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.jobs.NewRegistrationUsernameSyncJob; +import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob; import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob; import org.thoughtcrime.securesms.jobs.StorageSyncJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -56,7 +56,7 @@ public final class RegistrationLockFragment extends BaseRegistrationLockFragment ApplicationDependencies .getJobManager() .startChain(new StorageSyncJob()) - .then(new NewRegistrationUsernameSyncJob()) + .then(new ReclaimUsernameAndLinkJob()) .enqueueAndBlockUntilCompletion(TimeUnit.SECONDS.toMillis(10)); stopwatch.split("ContactRestore"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java index ccf503d641..a42a29f055 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java @@ -12,7 +12,7 @@ import androidx.savedstate.SavedStateRegistryOwner; import org.signal.core.util.Stopwatch; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.jobs.NewRegistrationUsernameSyncJob; +import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob; import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob; import org.thoughtcrime.securesms.jobs.StorageSyncJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -402,7 +402,7 @@ public final class RegistrationViewModel extends BaseRegistrationViewModel { ApplicationDependencies .getJobManager() .startChain(new StorageSyncJob()) - .then(new NewRegistrationUsernameSyncJob()) + .then(new ReclaimUsernameAndLinkJob()) .enqueueAndBlockUntilCompletion(TimeUnit.SECONDS.toMillis(10)); stopwatch.split("ContactRestore"); diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java index b95a0033ef..709f335fdb 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -794,6 +794,17 @@ public class SignalServiceAccountManager { } } + public UsernameLinkComponents reclaimUsernameAndLink(Username username, UsernameLinkComponents linkComponents) throws IOException { + try { + UsernameLink link = username.generateLink(linkComponents.getEntropy()); + UUID serverId = this.pushServiceSocket.confirmUsernameAndCreateNewLink(username, link); + + return new UsernameLinkComponents(link.getEntropy(), serverId); + } catch (BaseUsernameException e) { + throw new AssertionError(e); + } + } + public UsernameLinkComponents updateUsernameLink(UsernameLink newUsernameLink) throws IOException { UUID serverId = this.pushServiceSocket.createUsernameLink(Base64.encodeUrlSafeWithoutPadding(newUsernameLink.getEncryptedUsername()), true);