Add relink support and fix sad paths in all link flows.

Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
Cody Henthorne
2026-07-07 15:17:35 -04:00
committed by GitHub
parent 384344c91b
commit ecbdde592e
33 changed files with 356 additions and 87 deletions
@@ -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());
@@ -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 ;
@@ -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;
@@ -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()
}
@@ -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)
@@ -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<CertificateType> certificateTypes = SignalStore.phoneNumberPrivacy()
.getAllCertificateTypes();
@@ -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
@@ -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)
}
@@ -87,7 +87,7 @@ class RegisterLinkDeviceQrViewModel : ViewModel() {
}
return ProvisioningSocket.start<ProvisionMessage>(
mode = ProvisioningSocket.Mode.LINK,
mode = ProvisioningSocket.Mode.Link(linkAndSyncCapable = true),
identityKeyPair = IdentityKeyPair.generate(),
configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration(),
handler = { id, t ->
@@ -122,7 +122,7 @@ class RestoreViaQrViewModel : ViewModel() {
}
return ProvisioningSocket.start<RegistrationProvisionMessage>(
mode = ProvisioningSocket.Mode.REREG,
mode = ProvisioningSocket.Mode.Rereg,
identityKeyPair = IdentityKeyPair.generate(),
configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration(),
handler = { id, t ->
@@ -771,7 +771,7 @@ class AppRegistrationNetworkController(
fun startSocket() {
val handle = ProvisioningSocket.start<RegistrationProvisionMessage>(
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<LinkDeviceProvisioningEvent> = callbackFlow {
override fun startLinkDeviceProvisioning(allowLinkAndSync: Boolean): Flow<LinkDeviceProvisioningEvent> = callbackFlow {
val socketHandles = mutableListOf<Closeable>()
val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration()
fun startSocket() {
val handle = ProvisioningSocket.start<ProvisionMessage>(
mode = ProvisioningSocket.Mode.LINK,
mode = ProvisioningSocket.Mode.Link(linkAndSyncCapable = allowLinkAndSync),
identityKeyPair = IdentityKeyPair.generate(),
configuration = configuration,
handler = { id, t ->
@@ -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
@@ -248,8 +248,8 @@ class DebugNetworkController(
return delegate.startProvisioning()
}
override fun startLinkDeviceProvisioning(): Flow<NetworkController.LinkDeviceProvisioningEvent> {
return delegate.startLinkDeviceProvisioning()
override fun startLinkDeviceProvisioning(allowLinkAndSync: Boolean): Flow<NetworkController.LinkDeviceProvisioningEvent> {
return delegate.startLinkDeviceProvisioning(allowLinkAndSync)
}
override suspend fun registerAsLinkedDevice(
@@ -435,12 +435,12 @@ class DemoNetworkController(
)
}
override fun startLinkDeviceProvisioning(): Flow<NetworkController.LinkDeviceProvisioningEvent> = callbackFlow {
override fun startLinkDeviceProvisioning(allowLinkAndSync: Boolean): Flow<NetworkController.LinkDeviceProvisioningEvent> = callbackFlow {
val socketHandles = mutableListOf<Closeable>()
fun startSocket() {
val handle = ProvisioningSocket.start<ProvisionMessage>(
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<RegistrationProvisionMessage>(
mode = ProvisioningSocket.Mode.REREG,
mode = ProvisioningSocket.Mode.Rereg,
identityKeyPair = IdentityKeyPair.generate(),
configuration = serviceConfiguration,
handler = { id, t ->
@@ -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<LinkDeviceProvisioningEvent>
fun startLinkDeviceProvisioning(allowLinkAndSync: Boolean): Flow<LinkDeviceProvisioningEvent>
/**
* Performs the network call to register this device as a linked (secondary) device on a pre-existing
@@ -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(),
@@ -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<NavKey>.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<NavKey>.navigationEntries(
}
// --- Link account Screen
entry<RegistrationRoute.LinkAccount> {
entry<RegistrationRoute.LinkAccount> { 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,
@@ -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<NetworkController.LinkDeviceProvisioningEvent> {
return networkController.startLinkDeviceProvisioning()
fun startLinkDeviceProvisioning(): Flow<NetworkController.LinkDeviceProvisioningEvent> = 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
}
/**
@@ -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<RegistrationFlowEvent>(TAG) {
class RegistrationViewModel(
private val repository: RegistrationRepository,
savedStateHandle: SavedStateHandle,
startDestination: RegistrationRoute? = null
) : EventDrivenViewModel<RegistrationFlowEvent>(TAG) {
companion object {
private val TAG = Log.tag(RegistrationViewModel::class)
}
private var _state: MutableStateFlow<RegistrationFlowState> = savedStateHandle.getMutableStateFlow("registration_state", initialValue = RegistrationFlowState())
private var _state: MutableStateFlow<RegistrationFlowState> = savedStateHandle.getMutableStateFlow(
"registration_state",
initialValue = RegistrationFlowState(backStack = listOf(startDestination ?: RegistrationRoute.Welcome))
)
val state: StateFlow<RegistrationFlowState> = _state.asStateFlow()
private val finishChannel = Channel<Unit>(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 <T : ViewModel> create(modelClass: KClass<T>, extras: CreationExtras): T {
return RegistrationViewModel(repository, extras.createSavedStateHandle()) as T
return RegistrationViewModel(repository, extras.createSavedStateHandle(), startDestination) as T
}
}
}
@@ -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
}
}
)
@@ -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
}
@@ -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
)
@@ -37,7 +37,8 @@ import org.signal.registration.screens.util.navigateTo
class LinkAccountViewModel(
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
showCreateAccount: Boolean = true
) : EventDrivenViewModel<LinkAccountScreenEvent>(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<LinkAccountScreenState> = _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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val showCreateAccount: Boolean = true
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return LinkAccountViewModel(repository, parentState, parentEventEmitter) as T
return LinkAccountViewModel(repository, parentState, parentEventEmitter, showCreateAccount) as T
}
}
}
@@ -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
@@ -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()
}
@@ -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
)
@@ -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)
}
@@ -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))
@@ -407,6 +407,14 @@
<string name="MessageSyncScreen__learn_more">Learn more</string>
<!-- Button label that cancels the in-progress message sync -->
<string name="MessageSyncScreen__cancel">Cancel</string>
<!-- Title of the dialog shown when restoring messages from the other device fails -->
<string name="MessageSyncScreen__couldnt_restore_messages">Couldn\'t restore messages</string>
<!-- Body of the dialog shown when restoring messages fails, offering to retry or continue without message history -->
<string name="MessageSyncScreen__your_messages_couldnt_be_transferred">Your messages couldn\'t be transferred from your other device. You can try again, or continue without your message history.</string>
<!-- Button that retries the failed message restore -->
<string name="MessageSyncScreen__try_again">Try again</string>
<!-- Button that finishes linking without restoring message history after a failed restore -->
<string name="MessageSyncScreen__continue_without_messages">Continue without messages</string>
<!-- Title for the screen that shows a QR code the user scans with their existing primary device to link this device as a secondary -->
<string name="LinkAccountScreen__scan_this_code_to_link_your_account">Scan this code to link your account</string>
@@ -436,6 +444,12 @@
<string name="LinkAccountScreen__waiting_for_your_other_device">Waiting for your other device…</string>
<!-- Error dialog message shown when linking this device fails -->
<string name="LinkAccountScreen__error_linking_device">An error occurred while linking this device</string>
<!-- Title of the confirmation dialog shown when the scanned QR code belongs to a different account than the one this device is currently linked to -->
<string name="LinkAccountScreen__delete_app_data_question">Delete app data?</string>
<!-- Body of the confirmation dialog warning that continuing will erase all local data before relinking to a different account -->
<string name="LinkAccountScreen__you_are_attempting_to_link_a_different_account">You\'re attempting to link a different Signal account. If you continue, all Signal data currently on this device will be deleted.</string>
<!-- Destructive confirmation button that deletes all local data and restarts the app so it can be linked to a different account -->
<string name="LinkAccountScreen__delete_and_restart">Delete &amp; restart</string>
<!-- Content description for the button that closes the maximized QR code overlay -->
<string name="LinkAccountScreen__close_qr_code">Close QR code</string>
@@ -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))
@@ -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<NetworkController.LinkDeviceProvisioningMessage>(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<NetworkController.LinkDeviceProvisioningMessage>(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<NetworkController.LinkDeviceProvisioningEvent> {
val flow = MutableSharedFlow<NetworkController.LinkDeviceProvisioningEvent>(replay = 1)
every { mockRepository.startLinkDeviceProvisioning() } returns flow
@@ -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))
@@ -215,8 +215,8 @@ class ProvisioningSocket<T> private constructor(
"/v1/message" -> {
when (mode) {
Mode.REREG -> provisioningMessageDeferral.complete(cipher.decrypt(RegistrationProvisionEnvelope.ADAPTER.decode(body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult<T>)
Mode.LINK -> provisioningMessageDeferral.complete(cipher.decrypt(ProvisionEnvelope.ADAPTER.decode(body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult<T>)
is Mode.Rereg -> provisioningMessageDeferral.complete(cipher.decrypt(RegistrationProvisionEnvelope.ADAPTER.decode(body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult<T>)
is Mode.Link -> provisioningMessageDeferral.complete(cipher.decrypt(ProvisionEnvelope.ADAPTER.decode(body)) as SecondaryProvisioningCipher.ProvisioningDecryptResult<T>)
}
}
@@ -291,9 +291,9 @@ class ProvisioningSocket<T> 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 {