From ecbdde592e772d3fa4daf73574e945175ca2daef Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Tue, 7 Jul 2026 15:17:35 -0400 Subject: [PATCH] Add relink support and fix sad paths in all link flows. Co-authored-by: Greyson Parrelli --- .../securesms/PassphraseRequiredActivity.java | 21 ++++++- .../securesms/jobs/FcmRefreshJob.java | 6 ++ .../securesms/jobs/RefreshAttributesJob.java | 5 ++ .../jobs/RefreshSvrCredentialsJob.kt | 8 ++- .../securesms/jobs/RemoteConfigRefreshJob.kt | 6 ++ .../securesms/jobs/RotateCertificateJob.java | 6 ++ .../securesms/jobs/StorageSyncJob.kt | 6 ++ .../registration/ui/RegistrationActivity.kt | 11 +++- .../ui/link/RegisterLinkDeviceQrViewModel.kt | 2 +- .../ui/restore/RestoreViaQrViewModel.kt | 2 +- .../v2/AppRegistrationNetworkController.kt | 6 +- .../v2/AppRegistrationStorageController.kt | 2 +- .../sample/debug/DebugNetworkController.kt | 4 +- .../dependencies/DemoNetworkController.kt | 6 +- .../signal/registration/NetworkController.kt | 4 +- .../registration/RegistrationActivity.kt | 11 +++- .../registration/RegistrationNavigation.kt | 17 +++--- .../registration/RegistrationRepository.kt | 20 ++++++- .../registration/RegistrationViewModel.kt | 41 +++++++++----- .../screens/linkaccount/LinkAccountScreen.kt | 48 ++++++++++++---- .../linkaccount/LinkAccountScreenEvent.kt | 16 +++--- .../linkaccount/LinkAccountScreenState.kt | 4 +- .../linkaccount/LinkAccountViewModel.kt | 39 ++++++++++--- .../screens/messagesync/MessageSyncScreen.kt | 14 +++++ .../messagesync/MessageSyncScreenEvent.kt | 2 + .../messagesync/MessageSyncScreenState.kt | 3 +- .../messagesync/MessageSyncViewModel.kt | 15 ++++- .../phonenumber/PhoneNumberEntryViewModel.kt | 2 +- .../src/main/res/values/strings.xml | 14 +++++ .../registration/RegistrationViewModelTest.kt | 6 +- .../linkaccount/LinkAccountViewModelTest.kt | 55 ++++++++++++++++++- .../messagesync/MessageSyncViewModelTest.kt | 31 ++++++++++- .../api/provisioning/ProvisioningSocket.kt | 10 ++-- 33 files changed, 356 insertions(+), 87 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java index af96e551f5..fcbb9dacc7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java @@ -13,9 +13,11 @@ import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.AppForegroundObserver; import org.signal.core.util.logging.Log; import org.signal.core.util.tracing.Tracer; import org.signal.devicetransfer.TransferStatus; +import org.signal.registration.RegistrationRoute; import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberLockActivity; import org.thoughtcrime.securesms.crypto.MasterSecretUtil; import org.thoughtcrime.securesms.dependencies.AppDependencies; @@ -30,11 +32,10 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity; import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.registration.ui.RegistrationActivity; -import org.thoughtcrime.securesms.util.Environment; import org.thoughtcrime.securesms.restore.RestoreActivity; import org.thoughtcrime.securesms.service.KeyCachingService; -import org.signal.core.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.AppStartup; +import org.thoughtcrime.securesms.util.Environment; import org.thoughtcrime.securesms.util.TextSecurePreferences; import java.util.Locale; @@ -57,6 +58,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements private static final int STATE_TRANSFER_LOCKED = 9; private static final int STATE_CHANGE_NUMBER_LOCK = 10; private static final int STATE_TRANSFER_OR_RESTORE = 11; + private static final int STATE_RESUME_LINKING_REG = 12; private SignalServiceNetworkAccess networkAccess; private BroadcastReceiver clearKeyReceiver; @@ -155,6 +157,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements case STATE_TRANSFER_LOCKED: return getOldDeviceTransferLockedIntent(); case STATE_CHANGE_NUMBER_LOCK: return getChangeNumberLockIntent(); case STATE_TRANSFER_OR_RESTORE: return getTransferOrRestoreIntent(); + case STATE_RESUME_LINKING_REG: return getResumeLinkedRegistrationIntent(); default: return null; } } @@ -168,6 +171,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements return STATE_UI_BLOCKING_UPGRADE; } else if (!TextSecurePreferences.hasPromptedPushRegistration(this)) { return STATE_WELCOME_PUSH_SCREEN; + } else if (shouldResumeLinkingRegistration()) { + return STATE_RESUME_LINKING_REG; } else if (userCanTransferOrRestore()) { return STATE_TRANSFER_OR_RESTORE; } else if (SignalStore.storageService().getNeedsAccountRestore()) { @@ -192,6 +197,14 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements RestoreDecisionStateUtil.isDecisionPending(SignalStore.registration().getRestoreDecisionState()); } + private boolean shouldResumeLinkingRegistration() { + return Environment.USE_NEW_REGISTRATION && + SignalStore.account().isRegistered() && + !SignalStore.account().isPrimaryDevice() && + !SignalStore.registration().isRegistrationComplete() && + RestoreDecisionStateUtil.isDecisionPending(SignalStore.registration().getRestoreDecisionState()); + } + private boolean userMustCreateSignalPin() { return !SignalStore.registration().isRegistrationComplete() && !SignalStore.svr().hasPin() && @@ -246,6 +259,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements return getRoutedIntent(intent, MainActivity.clearTop(this)); } + private Intent getResumeLinkedRegistrationIntent() { + return org.signal.registration.RegistrationActivity.createIntent(this, MainActivity.clearTop(this), RegistrationRoute.MessageSync.INSTANCE); + } + private Intent getCreateProfileNameIntent() { Intent intent = CreateProfileActivity.getIntentForUserProfile(this); return getRoutedIntent(intent, getIntent()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java index 039db2d786..f9596f45f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/FcmRefreshJob.java @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.messages.IncomingMessageObserver; import org.thoughtcrime.securesms.net.SignalNetwork; import org.thoughtcrime.securesms.transport.RetryLaterException; import org.signal.core.util.PlayServicesUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.whispersystems.signalservice.api.NetworkResultUtil; import org.signal.network.exceptions.NonSuccessfulResponseCodeException; @@ -72,6 +73,11 @@ public class FcmRefreshJob extends BaseJob { @Override public void onRun() throws Exception { + if (TextSecurePreferences.isUnauthorizedReceived(context)) { + Log.i(TAG, "No longer authorized. Ignoring."); + return; + } + Log.i(TAG, "Reregistering FCM..."); boolean playServicesMissing = PlayServicesUtil.getPlayServicesStatus(context) == PlayServicesUtil.PlayServicesStatus.MISSING ; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java index 92be2b1306..f8ef77e832 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java @@ -91,6 +91,11 @@ public class RefreshAttributesJob extends BaseJob { return; } + if (TextSecurePreferences.isUnauthorizedReceived(context)) { + Log.i(TAG, "No longer authorized. Ignoring."); + return; + } + if (!forced && hasRefreshedThisAppCycle) { Log.d(TAG, "Already refreshed this app cycle. Skipping."); return; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshSvrCredentialsJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshSvrCredentialsJob.kt index 5b4e3afe7c..35b58fb1a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshSvrCredentialsJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshSvrCredentialsJob.kt @@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.pin.SvrRepository +import org.thoughtcrime.securesms.util.TextSecurePreferences import java.io.IOException import kotlin.time.Duration import kotlin.time.Duration.Companion.days @@ -24,7 +25,7 @@ class RefreshSvrCredentialsJob private constructor(parameters: Parameters) : Bas @JvmStatic fun enqueueIfNecessary() { - if (SignalStore.svr.hasPin() && SignalStore.account.isRegistered) { + if (SignalStore.svr.hasPin() && SignalStore.account.isRegistered && !TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application)) { val lastTimestamp = SignalStore.svr.lastRefreshAuthTimestamp if (lastTimestamp + FREQUENCY.inWholeMilliseconds < System.currentTimeMillis() || lastTimestamp > System.currentTimeMillis()) { AppDependencies.jobManager.add(RefreshSvrCredentialsJob()) @@ -55,6 +56,11 @@ class RefreshSvrCredentialsJob private constructor(parameters: Parameters) : Bas return } + if (TextSecurePreferences.isUnauthorizedReceived(context)) { + Log.i(TAG, "No longer authorized. Ignoring.") + return + } + SvrRepository.refreshAndStoreAuthorization() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.kt index cc9f72a42f..46bc412b70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.kt @@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.net.SignalNetwork import org.thoughtcrime.securesms.util.RemoteConfig +import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.signalservice.api.websocket.SignalWebSocket import kotlin.time.Duration.Companion.days @@ -44,6 +45,11 @@ class RemoteConfigRefreshJob private constructor(parameters: Parameters) : Job(p return Result.success() } + if (TextSecurePreferences.isUnauthorizedReceived(context)) { + Log.i(TAG, "No longer authorized. Ignoring.") + return Result.success() + } + return when (val result = SignalNetwork.remoteConfig.getRemoteConfig(SignalStore.remoteConfig.eTag)) { is NetworkResult.Success -> { RemoteConfig.update(result.result.config) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateCertificateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateCertificateJob.java index ed3a5eb377..5c877121ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateCertificateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateCertificateJob.java @@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.keyvalue.CertificateType; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.net.SignalNetwork; import org.thoughtcrime.securesms.util.ExceptionHelper; +import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.whispersystems.signalservice.api.NetworkResultUtil; import java.io.IOException; @@ -56,6 +57,11 @@ public final class RotateCertificateJob extends BaseJob { return; } + if (TextSecurePreferences.isUnauthorizedReceived(context)) { + Log.i(TAG, "No longer authorized. Ignoring."); + return; + } + synchronized (RotateCertificateJob.class) { Collection certificateTypes = SignalStore.phoneNumberPrivacy() .getAllCertificateTypes(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt index 89c2ebdd56..ea2e3f2458 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt @@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncValidations import org.thoughtcrime.securesms.storage.StoryDistributionListRecordProcessor import org.thoughtcrime.securesms.transport.RetryLaterException import org.thoughtcrime.securesms.util.RemoteConfig +import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage @@ -185,6 +186,11 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc return } + if (TextSecurePreferences.isUnauthorizedReceived(context)) { + Log.i(TAG, "No longer authorized. Ignoring.") + return + } + if (!Recipient.self().hasE164 || !Recipient.self().hasServiceId) { Log.w(TAG, "Missing E164 or ACI!") return diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt index e7f14340d0..b8cebdf283 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt @@ -12,6 +12,7 @@ import androidx.activity.viewModels import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.navigation.ActivityNavigator +import org.signal.registration.RegistrationRoute import org.thoughtcrime.securesms.BaseActivity import org.thoughtcrime.securesms.MainActivity import org.thoughtcrime.securesms.R @@ -84,7 +85,7 @@ class RegistrationActivity : BaseActivity() { @JvmStatic fun newIntentForNewRegistration(context: Context, originalIntent: Intent): Intent { return if (Environment.USE_NEW_REGISTRATION) { - org.signal.registration.RegistrationActivity.createIntent(context, MainActivity.clearTop(context)) + org.signal.registration.RegistrationActivity.createIntent(context, nextIntent = MainActivity.clearTop(context)) } else { Intent(context, RegistrationActivity::class.java).apply { putExtra(RE_REGISTRATION_EXTRA, false) @@ -96,7 +97,7 @@ class RegistrationActivity : BaseActivity() { @JvmStatic fun newIntentForReRegistration(context: Context): Intent { return if (Environment.USE_NEW_REGISTRATION) { - org.signal.registration.RegistrationActivity.createIntent(context, MainActivity.clearTop(context)) + org.signal.registration.RegistrationActivity.createIntent(context, nextIntent = MainActivity.clearTop(context)) } else { Intent(context, RegistrationActivity::class.java).apply { putExtra(RE_REGISTRATION_EXTRA, true) @@ -107,7 +108,11 @@ class RegistrationActivity : BaseActivity() { @JvmStatic fun newIntentForReLinkDevice(context: Context): Intent { return if (Environment.USE_NEW_REGISTRATION) { - org.signal.registration.RegistrationActivity.createIntent(context, MainActivity.clearTop(context)) + org.signal.registration.RegistrationActivity.createIntent( + context = context, + nextIntent = MainActivity.clearTop(context), + startDestination = RegistrationRoute.LinkAccount(showCreateAccount = false) + ) } else { Intent(context, RegistrationActivity::class.java) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/link/RegisterLinkDeviceQrViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/link/RegisterLinkDeviceQrViewModel.kt index f0d721102e..3a698a38d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/link/RegisterLinkDeviceQrViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/link/RegisterLinkDeviceQrViewModel.kt @@ -87,7 +87,7 @@ class RegisterLinkDeviceQrViewModel : ViewModel() { } return ProvisioningSocket.start( - mode = ProvisioningSocket.Mode.LINK, + mode = ProvisioningSocket.Mode.Link(linkAndSyncCapable = true), identityKeyPair = IdentityKeyPair.generate(), configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration(), handler = { id, t -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RestoreViaQrViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RestoreViaQrViewModel.kt index 4ea0a41fb8..bd5ba22bde 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RestoreViaQrViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RestoreViaQrViewModel.kt @@ -122,7 +122,7 @@ class RestoreViaQrViewModel : ViewModel() { } return ProvisioningSocket.start( - mode = ProvisioningSocket.Mode.REREG, + mode = ProvisioningSocket.Mode.Rereg, identityKeyPair = IdentityKeyPair.generate(), configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration(), handler = { id, t -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt index d995164082..e816c4d053 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt @@ -771,7 +771,7 @@ class AppRegistrationNetworkController( fun startSocket() { val handle = ProvisioningSocket.start( - mode = ProvisioningSocket.Mode.REREG, + mode = ProvisioningSocket.Mode.Rereg, identityKeyPair = IdentityKeyPair.generate(), configuration = configuration, handler = { id, t -> @@ -848,13 +848,13 @@ class AppRegistrationNetworkController( } } - override fun startLinkDeviceProvisioning(): Flow = callbackFlow { + override fun startLinkDeviceProvisioning(allowLinkAndSync: Boolean): Flow = callbackFlow { val socketHandles = mutableListOf() val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration() fun startSocket() { val handle = ProvisioningSocket.start( - mode = ProvisioningSocket.Mode.LINK, + mode = ProvisioningSocket.Mode.Link(linkAndSyncCapable = allowLinkAndSync), identityKeyPair = IdentityKeyPair.generate(), configuration = configuration, handler = { id, t -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt index 16c0394022..9c46244872 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt @@ -91,7 +91,7 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo val pni = SignalStore.account.pni ?: return@withContext null val e164 = SignalStore.account.e164 ?: return@withContext null val servicePassword = SignalStore.account.servicePassword ?: return@withContext null - val aep = SignalStore.account.accountEntropyPool ?: return@withContext null + val aep = SignalStore.account.accountEntropyPool val aciIdentityKeyPair = SignalStore.account.aciIdentityKey val pniIdentityKeyPair = SignalStore.account.pniIdentityKey diff --git a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt index 2aa78e3844..63912b5a1e 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt @@ -248,8 +248,8 @@ class DebugNetworkController( return delegate.startProvisioning() } - override fun startLinkDeviceProvisioning(): Flow { - return delegate.startLinkDeviceProvisioning() + override fun startLinkDeviceProvisioning(allowLinkAndSync: Boolean): Flow { + return delegate.startLinkDeviceProvisioning(allowLinkAndSync) } override suspend fun registerAsLinkedDevice( diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt index 9b36f122bc..f91c670140 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt @@ -435,12 +435,12 @@ class DemoNetworkController( ) } - override fun startLinkDeviceProvisioning(): Flow = callbackFlow { + override fun startLinkDeviceProvisioning(allowLinkAndSync: Boolean): Flow = callbackFlow { val socketHandles = mutableListOf() fun startSocket() { val handle = ProvisioningSocket.start( - mode = ProvisioningSocket.Mode.LINK, + mode = ProvisioningSocket.Mode.Link(linkAndSyncCapable = allowLinkAndSync), identityKeyPair = IdentityKeyPair.generate(), configuration = serviceConfiguration, handler = { id, t -> @@ -659,7 +659,7 @@ class DemoNetworkController( fun startSocket() { val handle = ProvisioningSocket.start( - mode = ProvisioningSocket.Mode.REREG, + mode = ProvisioningSocket.Mode.Rereg, identityKeyPair = IdentityKeyPair.generate(), configuration = serviceConfiguration, handler = { id, t -> diff --git a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt index 2ab260e25c..3731195230 100644 --- a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt +++ b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt @@ -240,8 +240,10 @@ interface NetworkController { * - [LinkDeviceProvisioningEvent.Error] if the provisioning session encounters an unrecoverable error. * * The flow manages socket lifecycle (rotation, keep-alive) internally. Cancel the collecting coroutine to stop provisioning. + * + * @param allowLinkAndSync Whether we allow data sync during linking. Normally allowed, but disabled for re-links. */ - fun startLinkDeviceProvisioning(): Flow + fun startLinkDeviceProvisioning(allowLinkAndSync: Boolean): Flow /** * Performs the network call to register this device as a linked (secondary) device on a pre-existing diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt index 35a9247c03..7ccea399da 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt @@ -26,19 +26,25 @@ class RegistrationActivity : ComponentActivity() { companion object { private const val NEXT_INTENT_EXTRA = "next_intent" + private const val START_DESTINATION_EXTRA = "start_destination" /** * @param nextIntent An optional intent to launch once registration completes successfully. This is how the caller * (which lives outside this module) routes the user back into the main app, since the launching activity will * typically have finished itself. + * @param startDestination An optional route to open directly instead of resuming a previous flow. Used, for example, + * to send a deregistered linked device straight to the link-device screen. */ @JvmStatic @JvmOverloads - fun createIntent(context: Context, nextIntent: Intent? = null): Intent { + fun createIntent(context: Context, nextIntent: Intent? = null, startDestination: RegistrationRoute? = null): Intent { return Intent(context, RegistrationActivity::class.java).apply { if (nextIntent != null) { putExtra(NEXT_INTENT_EXTRA, nextIntent) } + if (startDestination != null) { + putExtra(START_DESTINATION_EXTRA, startDestination) + } } } } @@ -57,11 +63,14 @@ class RegistrationActivity : ComponentActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) + val startDestination = IntentCompat.getParcelableExtra(intent, START_DESTINATION_EXTRA, RegistrationRoute::class.java) + setContent { SignalTheme(incognitoKeyboardEnabled = false) { Surface(modifier = Modifier.fillMaxSize()) { RegistrationNavHost( registrationRepository = repository, + startDestination = startDestination, modifier = Modifier .fillMaxSize() .navigationBarsPadding(), diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index 5f4c9ed228..c860e5a81c 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -126,7 +126,7 @@ sealed interface RegistrationRoute : NavKey, Parcelable { data class AllowNotifications(val nextRoute: RegistrationRoute) : RegistrationRoute @Serializable - data object LinkAccount : RegistrationRoute + data class LinkAccount(val showCreateAccount: Boolean = true) : RegistrationRoute @Serializable data object MessageSync : RegistrationRoute @@ -262,6 +262,7 @@ private const val PIN_LEARN_MORE_URL = "https://support.signal.org/hc/articles/3 * @param registrationRepository The repository for registration data. * @param registrationViewModel Optional ViewModel for testing. If null, creates one internally. * @param permissionsState Optional permissions state for testing. If null, creates one internally. + * @param startDestination Optional route to open directly as the sole start destination, instead of showing [RegistrationRoute.Welcome] or restoring a previous flow. * @param modifier Modifier to be applied to the NavDisplay. * @param onRegistrationComplete Callback invoked when registration is successfully completed. */ @@ -271,11 +272,12 @@ fun RegistrationNavHost( registrationRepository: RegistrationRepository, registrationViewModel: RegistrationViewModel? = null, permissionsState: MultiplePermissionsState? = null, + startDestination: RegistrationRoute? = null, modifier: Modifier = Modifier, onRegistrationComplete: () -> Unit = {} ) { val viewModel: RegistrationViewModel = registrationViewModel ?: viewModel( - factory = RegistrationViewModel.Factory(registrationRepository) + factory = RegistrationViewModel.Factory(registrationRepository, startDestination) ) val registrationState by viewModel.state.collectAsStateWithLifecycle() @@ -382,9 +384,9 @@ private fun EntryProviderScope.navigationEntries( WelcomeScreenEvents.Continue -> navigateRequestingPermissions(RegistrationRoute.PhoneNumberEntry) WelcomeScreenEvents.LinkDevice -> { if (registrationViewModel.getRequiredLinkedDevicePermission().isNullOrBlank()) { - parentEventEmitter.navigateTo(RegistrationRoute.LinkAccount) + parentEventEmitter.navigateTo(RegistrationRoute.LinkAccount()) } else { - parentEventEmitter.navigateTo(RegistrationRoute.AllowNotifications(RegistrationRoute.LinkAccount)) + parentEventEmitter.navigateTo(RegistrationRoute.AllowNotifications(RegistrationRoute.LinkAccount())) } } WelcomeScreenEvents.HasOldPhone -> navigateRequestingPermissions(RegistrationRoute.QuickRestoreQrScan) @@ -430,17 +432,18 @@ private fun EntryProviderScope.navigationEntries( } // --- Link account Screen - entry { + entry { key -> val viewModel: LinkAccountViewModel = viewModel( factory = LinkAccountViewModel.Factory( repository = registrationRepository, parentState = registrationViewModel.state, - parentEventEmitter = registrationViewModel::onEvent + parentEventEmitter = registrationViewModel::onEvent, + showCreateAccount = key.showCreateAccount ) ) val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current - val url = stringResource(R.string.terms_and_privacy_policy_url) // TODO [regv5] update with proper url + val url = "https://support.signal.org/hc/articles/360007320451-Troubleshooting-multiple-devices" // TODO [regv5] update with proper url LinkAccountScreen( state = state, diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index f050547f80..0c70f829fc 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -294,8 +294,24 @@ class RegistrationRepository(val context: Context, val networkController: Networ * Starts a provisioning session for QR-based device linking. * See [NetworkController.startLinkDeviceProvisioning]. */ - fun startLinkDeviceProvisioning(): Flow { - return networkController.startLinkDeviceProvisioning() + fun startLinkDeviceProvisioning(): Flow = flow { + emitAll(networkController.startLinkDeviceProvisioning(allowLinkAndSync = isCleanStart())) + } + + /** + * True if this device is linked to a different account (different ACI) than the one advertised in + * [message]. Returns false for a fresh (never-registered) device. + */ + suspend fun isProvisioningForDifferentAccount(message: NetworkController.LinkDeviceProvisioningMessage): Boolean { + val previousAci = storageController.getPreExistingRegistrationData()?.aci ?: return false + return previousAci != ACI.parseOrThrow(message.aci) + } + + /** + * True if this device has no pre-existing registration (a fresh, never-registered device). + */ + suspend fun isCleanStart(): Boolean { + return storageController.getPreExistingRegistrationData() == null } /** diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt index 2de673203f..e0b85ea056 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt @@ -30,13 +30,20 @@ import kotlin.reflect.KClass * ViewModel shared across the registration flow. * Manages state and logic for registration screens. */ -class RegistrationViewModel(private val repository: RegistrationRepository, savedStateHandle: SavedStateHandle) : EventDrivenViewModel(TAG) { +class RegistrationViewModel( + private val repository: RegistrationRepository, + savedStateHandle: SavedStateHandle, + startDestination: RegistrationRoute? = null +) : EventDrivenViewModel(TAG) { companion object { private val TAG = Log.tag(RegistrationViewModel::class) } - private var _state: MutableStateFlow = savedStateHandle.getMutableStateFlow("registration_state", initialValue = RegistrationFlowState()) + private var _state: MutableStateFlow = savedStateHandle.getMutableStateFlow( + "registration_state", + initialValue = RegistrationFlowState(backStack = listOf(startDestination ?: RegistrationRoute.Welcome)) + ) val state: StateFlow = _state.asStateFlow() private val finishChannel = Channel(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -45,17 +52,21 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save val resultBus = ResultEventBus() init { - _state.value = _state.value.copy(isRestoringNavigationState = true) - viewModelScope.launch { - val restored = repository.restoreFlowState() - if (restored != null) { - Log.i(TAG, "[init] Restored flow state from disk. Backstack size: ${restored.backStack.size}, hasSession: ${restored.sessionMetadata != null}") - _state.value = validateRestoredState(restored).copy(isRestoringNavigationState = false) - } else { - _state.value = _state.value.copy( - preExistingRegistrationData = repository.getPreExistingRegistrationData(), - isRestoringNavigationState = false - ) + if (startDestination != null) { + _state.value = _state.value.copy(isRestoringNavigationState = false) + } else { + _state.value = _state.value.copy(isRestoringNavigationState = true) + viewModelScope.launch { + val restored = repository.restoreFlowState() + if (restored != null) { + Log.i(TAG, "[init] Restored flow state from disk. Backstack size: ${restored.backStack.size}, hasSession: ${restored.sessionMetadata != null}") + _state.value = validateRestoredState(restored).copy(isRestoringNavigationState = false) + } else { + _state.value = _state.value.copy( + preExistingRegistrationData = repository.getPreExistingRegistrationData(), + isRestoringNavigationState = false + ) + } } } } @@ -203,9 +214,9 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save } } - class Factory(private val repository: RegistrationRepository) : ViewModelProvider.Factory { + class Factory(private val repository: RegistrationRepository, private val startDestination: RegistrationRoute? = null) : ViewModelProvider.Factory { override fun create(modelClass: KClass, extras: CreationExtras): T { - return RegistrationViewModel(repository, extras.createSavedStateHandle()) as T + return RegistrationViewModel(repository, extras.createSavedStateHandle(), startDestination) as T } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreen.kt index 1640a79c36..8c09763f44 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreen.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import kotlinx.coroutines.delay import org.signal.core.ui.WindowBreakpoint import org.signal.core.ui.compose.AllDevicePreviews @@ -161,6 +162,19 @@ private fun StateDialogs( onDismiss = { onEvent(LinkAccountScreenEvent.DismissError) } ) } + + if (state.showDeleteDataDialog) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.LinkAccountScreen__delete_app_data_question), + body = stringResource(R.string.LinkAccountScreen__you_are_attempting_to_link_a_different_account), + confirm = stringResource(R.string.LinkAccountScreen__delete_and_restart), + confirmColor = MaterialTheme.colorScheme.error, + dismiss = stringResource(android.R.string.cancel), + onConfirm = { onEvent(LinkAccountScreenEvent.ConfirmDeleteAndRelink) }, + onDeny = { onEvent(LinkAccountScreenEvent.CancelDeleteAndRelink) }, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) + } } @Composable @@ -194,12 +208,16 @@ private fun OnePane( ) } }, - footer = { - OnePaneFooterContent( - params = params, - isElevated = scrollState.canScrollForward, - onEvent = onEvent - ) + footer = if (state.showCreateAccount) { + { + OnePaneFooterContent( + params = params, + isElevated = scrollState.canScrollForward, + onEvent = onEvent + ) + } + } else { + null } ) } @@ -231,12 +249,16 @@ private fun TwoPane( expandButtonVisible = expandButtonVisible ) }, - footer = { - TwoPaneFooterContent( - params = params, - isElevated = false, - onEvent = onEvent - ) + footer = if (state.showCreateAccount) { + { + TwoPaneFooterContent( + params = params, + isElevated = false, + onEvent = onEvent + ) + } + } else { + null } ) } @@ -678,6 +700,8 @@ private fun LinkAccountScreenPreview() { LinkAccountScreenEvent.HideOverlayClick -> displayQrOverlay = false LinkAccountScreenEvent.RetryQrCode -> Unit LinkAccountScreenEvent.DismissError -> Unit + LinkAccountScreenEvent.ConfirmDeleteAndRelink -> Unit + LinkAccountScreenEvent.CancelDeleteAndRelink -> Unit } } ) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenEvent.kt b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenEvent.kt index a155301a1a..526de6e867 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenEvent.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenEvent.kt @@ -5,11 +5,13 @@ package org.signal.registration.screens.linkaccount -sealed class LinkAccountScreenEvent { - data object GetHelpClick : LinkAccountScreenEvent() - data object CreateAccountClick : LinkAccountScreenEvent() - data object DisplayOverlayClick : LinkAccountScreenEvent() - data object HideOverlayClick : LinkAccountScreenEvent() - data object RetryQrCode : LinkAccountScreenEvent() - data object DismissError : LinkAccountScreenEvent() +sealed interface LinkAccountScreenEvent { + data object GetHelpClick : LinkAccountScreenEvent + data object CreateAccountClick : LinkAccountScreenEvent + data object DisplayOverlayClick : LinkAccountScreenEvent + data object HideOverlayClick : LinkAccountScreenEvent + data object RetryQrCode : LinkAccountScreenEvent + data object DismissError : LinkAccountScreenEvent + data object ConfirmDeleteAndRelink : LinkAccountScreenEvent + data object CancelDeleteAndRelink : LinkAccountScreenEvent } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenState.kt b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenState.kt index 7ed693c513..6bbb8c31ae 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenState.kt @@ -12,5 +12,7 @@ data class LinkAccountScreenState( val displayQrOverlay: Boolean = false, val isRegistering: Boolean = false, val isWaitingForPrimary: Boolean = false, - val showError: Boolean = false + val showError: Boolean = false, + val showDeleteDataDialog: Boolean = false, + val showCreateAccount: Boolean = true ) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt index 5d6055dd4e..956358383c 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt @@ -37,7 +37,8 @@ import org.signal.registration.screens.util.navigateTo class LinkAccountViewModel( private val repository: RegistrationRepository, private val parentState: StateFlow, - private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + showCreateAccount: Boolean = true ) : EventDrivenViewModel(TAG) { companion object { @@ -45,10 +46,10 @@ class LinkAccountViewModel( private const val DEVICE_NAME = "Android" } - private val _state = MutableStateFlow(LinkAccountScreenState()) + private val _state = MutableStateFlow(LinkAccountScreenState(showCreateAccount = showCreateAccount)) val state: StateFlow = _state .onEach { Log.d(TAG, "[State] $it") } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LinkAccountScreenState()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), _state.value) private var provisioningJob: Job? = null @@ -79,6 +80,14 @@ class LinkAccountViewModel( startProvisioning() state.copy(qrCodeState = QrState.Loading, showError = false) } + LinkAccountScreenEvent.ConfirmDeleteAndRelink -> { + viewModelScope.launch { repository.clearLocalDataAndRestart() } + state.copy(showDeleteDataDialog = false) + } + LinkAccountScreenEvent.CancelDeleteAndRelink -> { + startProvisioning() + state.copy(qrCodeState = QrState.Loading, showDeleteDataDialog = false) + } } stateEmitter(result) } @@ -108,12 +117,25 @@ class LinkAccountViewModel( } private suspend fun handleProvisioningMessage(message: NetworkController.LinkDeviceProvisioningMessage) { + if (repository.isProvisioningForDifferentAccount(message)) { + Log.w(TAG, "[Register] Provisioning message is for a different account prompting to delete local data") + _state.update { it.copy(isRegistering = false, showDeleteDataDialog = true) } + return + } + + val isCleanStart = repository.isCleanStart() + _state.update { it.copy(isRegistering = true, qrCodeState = QrState.Scanned) } when (val result = repository.registerAsLinkedDevice(message, DEVICE_NAME)) { is RequestResult.Success -> { - Log.i(TAG, "[Register] Success! hasLinkAndSyncBackup: ${result.result.hasLinkAndSyncBackup}") - if (result.result.hasLinkAndSyncBackup) { + Log.i(TAG, "[Register] Success! hasLinkAndSyncBackup: ${result.result.hasLinkAndSyncBackup}, isCleanStart: $isCleanStart") + + if (result.result.hasLinkAndSyncBackup && !isCleanStart) { + Log.w(TAG, "[Register] Link-and-sync offered on a relink over existing data, skipping import") + } + + if (result.result.hasLinkAndSyncBackup && isCleanStart) { // Wait here until the primary actually makes the backup available or tells us not to expect one _state.update { it.copy(isRegistering = false, isWaitingForPrimary = true) } val waitResult = repository.awaitLinkAndSyncArchive() @@ -137,7 +159,7 @@ class LinkAccountViewModel( } } } else { - // No link-and-sync backup, restore from storage service immediately, then finish + // No valid link-and-sync backup, restore from storage service immediately, then finish repository.restoreLinkedDeviceFromStorageService() _state.update { it.copy(isRegistering = false) } parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete) @@ -169,10 +191,11 @@ class LinkAccountViewModel( class Factory( private val repository: RegistrationRepository, private val parentState: StateFlow, - private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + private val showCreateAccount: Boolean = true ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return LinkAccountViewModel(repository, parentState, parentEventEmitter) as T + return LinkAccountViewModel(repository, parentState, parentEventEmitter, showCreateAccount) as T } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreen.kt index fab50a2519..584156c5b3 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreen.kt @@ -36,9 +36,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import org.signal.core.ui.WindowBreakpoint import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.rememberWindowBreakpoint @@ -68,6 +70,18 @@ fun MessageSyncScreen( is RegistrationScaffold.Params.TwoPane -> TwoPane(layoutParams, state, onEvent) } } + + if (state.showSyncFailedDialog) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.MessageSyncScreen__couldnt_restore_messages), + body = stringResource(R.string.MessageSyncScreen__your_messages_couldnt_be_transferred), + confirm = stringResource(R.string.MessageSyncScreen__try_again), + onConfirm = { onEvent(MessageSyncScreenEvent.RetryClick) }, + dismiss = stringResource(R.string.MessageSyncScreen__continue_without_messages), + onDeny = { onEvent(MessageSyncScreenEvent.ContinueWithoutMessagesClick) }, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) + } } @Composable diff --git a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenEvent.kt b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenEvent.kt index 4d660ddf08..61a413f6d5 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenEvent.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenEvent.kt @@ -8,4 +8,6 @@ package org.signal.registration.screens.messagesync sealed class MessageSyncScreenEvent { data object LearnMoreClick : MessageSyncScreenEvent() data object CancelClick : MessageSyncScreenEvent() + data object RetryClick : MessageSyncScreenEvent() + data object ContinueWithoutMessagesClick : MessageSyncScreenEvent() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenState.kt b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenState.kt index a6505eb077..97bfceff21 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenState.kt @@ -11,5 +11,6 @@ import org.signal.core.util.bytes data class MessageSyncScreenState( val downloadedBytes: ByteSize = 0.bytes, val totalBytes: ByteSize = 0.bytes, - val isFinishing: Boolean = false + val isFinishing: Boolean = false, + val showSyncFailedDialog: Boolean = false ) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt index b2a86eba6a..93b44b4a36 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.signal.core.util.bytes import org.signal.core.util.logging.Log import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationFlowState @@ -67,8 +68,8 @@ class MessageSyncViewModel( finish() } is LinkAndSyncProgress.Failed -> { - Log.w(TAG, "[MessageSync] Link-and-sync failed; restoring from storage service then completing (still linked).", progress.cause) - finish() + Log.w(TAG, "[MessageSync] Link-and-sync failed; prompting the user to retry or continue without messages.", progress.cause) + _state.update { it.copy(isFinishing = false, showSyncFailedDialog = true) } } is LinkAndSyncProgress.RelinkRequired -> { Log.w(TAG, "[MessageSync] Primary requested re-link; wiping local data and restarting.") @@ -108,6 +109,16 @@ class MessageSyncViewModel( finish(cancelDownload = true) state.copy(isFinishing = true) } + MessageSyncScreenEvent.RetryClick -> { + Log.i(TAG, "[MessageSync] User retrying link-and-sync after a failure.") + startRestore() + state.copy(showSyncFailedDialog = false, isFinishing = false, downloadedBytes = 0.bytes, totalBytes = 0.bytes) + } + MessageSyncScreenEvent.ContinueWithoutMessagesClick -> { + Log.i(TAG, "[MessageSync] User continuing without message history after a failed link-and-sync.") + finish() + state.copy(showSyncFailedDialog = false, isFinishing = true) + } } stateEmitter(result) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt index 4995b56b78..c2f1111235 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt @@ -133,7 +133,7 @@ class PhoneNumberEntryViewModel( } } is PhoneNumberEntryScreenEvents.LinkDevice -> { - parentEventEmitter.navigateTo(RegistrationRoute.LinkAccount) + parentEventEmitter.navigateTo(RegistrationRoute.LinkAccount()) } is PhoneNumberEntryScreenEvents.CaptchaCompleted -> { stateEmitter(applyCaptchaCompleted(state, event.token, parentEventEmitter)) diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index a8d6f05a72..828631afa0 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -407,6 +407,14 @@ Learn more Cancel + + Couldn\'t restore messages + + Your messages couldn\'t be transferred from your other device. You can try again, or continue without your message history. + + Try again + + Continue without messages Scan this code to link your account @@ -436,6 +444,12 @@ Waiting for your other device… An error occurred while linking this device + + Delete app data? + + You\'re attempting to link a different Signal account. If you continue, all Signal data currently on this device will be deleted. + + Delete & restart Close QR code diff --git a/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelTest.kt index ec78dcb82e..3112c45a93 100644 --- a/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelTest.kt @@ -468,7 +468,7 @@ class RegistrationViewModelTest { advanceUntilIdle() val initialState = RegistrationFlowState( - backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry, RegistrationRoute.LinkAccount) + backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry, RegistrationRoute.LinkAccount()) ) val result = viewModel.applyEvent( @@ -494,7 +494,7 @@ class RegistrationViewModelTest { advanceUntilIdle() val initialState = RegistrationFlowState( - backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry, RegistrationRoute.LinkAccount) + backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry, RegistrationRoute.LinkAccount()) ) val result = viewModel.applyEvent(initialState, RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.PhoneNumberEntry)) @@ -511,7 +511,7 @@ class RegistrationViewModelTest { advanceUntilIdle() val initialState = RegistrationFlowState( - backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.LinkAccount) + backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.LinkAccount()) ) val result = viewModel.applyEvent(initialState, RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.PhoneNumberEntry)) diff --git a/feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountViewModelTest.kt index 9821cd8a1f..b3a3751ad1 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountViewModelTest.kt @@ -55,6 +55,7 @@ class LinkAccountViewModelTest { Dispatchers.setMain(testDispatcher) mockRepository = mockk(relaxed = true) every { mockRepository.startLinkDeviceProvisioning() } returns emptyFlow() + coEvery { mockRepository.isCleanStart() } returns true emittedParentEvents = mutableListOf() parentEventEmitter = { event -> emittedParentEvents.add(event) } emittedStates = mutableListOf() @@ -131,6 +132,21 @@ class LinkAccountViewModelTest { assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.MessageSync)) } + @Test + fun `link-and-sync offered on a relink over existing data skips import and restores from storage service`() = runTest(testDispatcher) { + val flow = givenProvisioningFlow() + val message = mockk(relaxed = true) + coEvery { mockRepository.isCleanStart() } returns false + coEvery { mockRepository.registerAsLinkedDevice(message, any()) } returns RequestResult.Success(LinkedDeviceResult(hasLinkAndSyncBackup = true)) + + val viewModel = createViewModel() + flow.emit(NetworkController.LinkDeviceProvisioningEvent.MessageReceived(message)) + + coVerify(exactly = 0) { mockRepository.awaitLinkAndSyncArchive() } + coVerify { mockRepository.restoreLinkedDeviceFromStorageService() } + assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete)) + } + @Test fun `link-and-sync offered but archive never arrives navigates to FullyComplete`() = runTest(testDispatcher) { val flow = givenProvisioningFlow() @@ -213,7 +229,7 @@ class LinkAccountViewModelTest { @Test fun `applyEvent CreateAccountClick from link-device-first flow routes through Permissions`() = runTest(testDispatcher) { val viewModel = createViewModel( - RegistrationFlowState(backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.LinkAccount)) + RegistrationFlowState(backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.LinkAccount())) ) viewModel.applyEvent(LinkAccountScreenState(), LinkAccountScreenEvent.CreateAccountClick, stateEmitter) @@ -234,7 +250,7 @@ class LinkAccountViewModelTest { RegistrationRoute.Welcome, RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry), RegistrationRoute.PhoneNumberEntry, - RegistrationRoute.LinkAccount + RegistrationRoute.LinkAccount() ) ) ) @@ -246,6 +262,41 @@ class LinkAccountViewModelTest { ) } + @Test + fun `provisioning message for a different account prompts to delete data without registering`() = runTest(testDispatcher) { + val flow = givenProvisioningFlow() + val message = mockk(relaxed = true) + coEvery { mockRepository.isProvisioningForDifferentAccount(message) } returns true + + val viewModel = createViewModel() + flow.emit(NetworkController.LinkDeviceProvisioningEvent.MessageReceived(message)) + + assertThat(viewModel.state.value.showDeleteDataDialog).isTrue() + assertThat(viewModel.state.value.isRegistering).isFalse() + coVerify(exactly = 0) { mockRepository.registerAsLinkedDevice(any(), any()) } + } + + @Test + fun `applyEvent ConfirmDeleteAndRelink wipes local data and dismisses the dialog`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + viewModel.applyEvent(LinkAccountScreenState(showDeleteDataDialog = true), LinkAccountScreenEvent.ConfirmDeleteAndRelink, stateEmitter) + + coVerify { mockRepository.clearLocalDataAndRestart() } + assertThat(emittedStates.last().showDeleteDataDialog).isFalse() + } + + @Test + fun `applyEvent CancelDeleteAndRelink dismisses the dialog and restarts provisioning`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + viewModel.applyEvent(LinkAccountScreenState(showDeleteDataDialog = true), LinkAccountScreenEvent.CancelDeleteAndRelink, stateEmitter) + + assertThat(emittedStates.last().qrCodeState).isEqualTo(QrState.Loading) + assertThat(emittedStates.last().showDeleteDataDialog).isFalse() + coVerify(exactly = 0) { mockRepository.clearLocalDataAndRestart() } + } + private fun givenProvisioningFlow(): MutableSharedFlow { val flow = MutableSharedFlow(replay = 1) every { mockRepository.startLinkDeviceProvisioning() } returns flow diff --git a/feature/registration/src/test/java/org/signal/registration/screens/messagesync/MessageSyncViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/messagesync/MessageSyncViewModelTest.kt index 0def7decf5..5b8c1eb7d0 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/messagesync/MessageSyncViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/messagesync/MessageSyncViewModelTest.kt @@ -13,6 +13,7 @@ import assertk.assertions.isTrue import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow @@ -67,10 +68,36 @@ class MessageSyncViewModelTest { } @Test - fun `restore Failed still restores from storage service then navigates to FullyComplete`() = runTest(testDispatcher) { + fun `restore Failed shows the retry dialog and does not finish registration`() = runTest(testDispatcher) { every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.Failed()) - createViewModel() + val viewModel = createViewModel() + + assertThat(viewModel.state.value.showSyncFailedDialog).isTrue() + assertThat(viewModel.state.value.isFinishing).isFalse() + coVerify(exactly = 0) { mockRepository.restoreLinkedDeviceFromStorageService() } + assertThat(emittedParentEvents).doesNotContain(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete)) + } + + @Test + fun `applyEvent RetryClick clears the dialog and restarts the restore`() = runTest(testDispatcher) { + every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.Failed()) + + val viewModel = createViewModel() + var emitted: MessageSyncScreenState? = null + viewModel.applyEvent(viewModel.state.value, MessageSyncScreenEvent.RetryClick) { emitted = it } + + assertThat(emitted!!.showSyncFailedDialog).isFalse() + // Once on init, once on retry. + verify(exactly = 2) { mockRepository.restoreLinkAndSyncBackup() } + } + + @Test + fun `applyEvent ContinueWithoutMessagesClick restores from storage service then navigates to FullyComplete`() = runTest(testDispatcher) { + every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.Failed()) + + val viewModel = createViewModel() + viewModel.applyEvent(viewModel.state.value, MessageSyncScreenEvent.ContinueWithoutMessagesClick) {} coVerify { mockRepository.restoreLinkedDeviceFromStorageService() } assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete)) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/provisioning/ProvisioningSocket.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/provisioning/ProvisioningSocket.kt index 51a3d7007b..e209adc941 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/provisioning/ProvisioningSocket.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/provisioning/ProvisioningSocket.kt @@ -215,8 +215,8 @@ class ProvisioningSocket private constructor( "/v1/message" -> { when (mode) { - Mode.REREG -> provisioningMessageDeferral.complete(cipher.decrypt(RegistrationProvisionEnvelope.ADAPTER.decode(body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult) - Mode.LINK -> provisioningMessageDeferral.complete(cipher.decrypt(ProvisionEnvelope.ADAPTER.decode(body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult) + is Mode.Rereg -> provisioningMessageDeferral.complete(cipher.decrypt(RegistrationProvisionEnvelope.ADAPTER.decode(body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult) + is Mode.Link -> provisioningMessageDeferral.complete(cipher.decrypt(ProvisionEnvelope.ADAPTER.decode(body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult) } } @@ -291,9 +291,9 @@ class ProvisioningSocket private constructor( } } - enum class Mode(val host: String, val params: String) { - REREG("rereg", ""), - LINK("linkdevice", "&capabilities=backup5") + sealed class Mode(val host: String, val params: String) { + data object Rereg : Mode("rereg", "") + data class Link(val linkAndSyncCapable: Boolean) : Mode("linkdevice", if (linkAndSyncCapable) "&capabilities=backup5" else "") } fun interface ProvisioningSocketExceptionHandler {