From 512df1ec1bcf198e9650103fbba757cd3baf5b4b Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Thu, 2 Jul 2026 15:25:16 -0400 Subject: [PATCH] Implement phone friendly link device registration flow. --- .../registration/RegistrationFlowEvent.kt | 11 +- .../registration/RegistrationViewModel.kt | 19 ++- .../screens/linkaccount/LinkAccountScreen.kt | 157 +++++++++++++----- .../linkaccount/LinkAccountViewModel.kt | 7 +- .../phonenumber/PhoneNumberEntryScreen.kt | 9 +- .../PhoneNumberEntryScreenEvents.kt | 4 + .../phonenumber/PhoneNumberEntryViewModel.kt | 3 + .../screens/util/EmitterExtensions.kt | 4 +- .../registration/RegistrationViewModelTest.kt | 62 +++++++ .../linkaccount/LinkAccountViewModelTest.kt | 35 +++- .../PhoneNumberEntryViewModelTest.kt | 16 ++ 11 files changed, 266 insertions(+), 61 deletions(-) diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt index 2dfd73a818..1a20ddfd62 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt @@ -10,12 +10,19 @@ import org.signal.core.models.MasterKey import org.signal.core.util.censor sealed interface RegistrationFlowEvent { - /** Navigate to a specific screen. */ - data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent + /** + * Navigate to a specific screen. + * + * @param popCurrent Remove the current screen from the backstack + */ + data class NavigateToScreen(val route: RegistrationRoute, val popCurrent: Boolean = false) : RegistrationFlowEvent /** Navigate back one screen. */ data object NavigateBack : RegistrationFlowEvent + /** Pop the back stack back to an existing screen, removing everything above it. Replaces the current screen if the route isn't on the stack. */ + data class NavigateBackToScreen(val route: RegistrationRoute) : RegistrationFlowEvent + /** We've encountered some irrecoverable state where the best course of action is to completely reset registration. */ data object ResetState : RegistrationFlowEvent 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 35cb3a51b5..834405a090 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt @@ -73,6 +73,7 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save is RegistrationFlowEvent.Registered -> state.copy(accountEntropyPool = event.accountEntropyPool, storageCapable = event.storageCapable) is RegistrationFlowEvent.MasterKeyRestoredFromSvr -> state.copy(temporaryMasterKey = event.masterKey) is RegistrationFlowEvent.NavigateToScreen -> applyNavigationToScreenEvent(state, event) + is RegistrationFlowEvent.NavigateBackToScreen -> applyNavigateBackToScreenEvent(state, event) is RegistrationFlowEvent.NavigateBack -> { if (state.backStack.size > 1) { state.copy(backStack = state.backStack.dropLast(1)) @@ -98,10 +99,10 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save } private fun applyNavigationToScreenEvent(inputState: RegistrationFlowState, event: RegistrationFlowEvent.NavigateToScreen): RegistrationFlowState { - val backStack = if (event.route.clearsBackStack()) { - listOf(event.route) - } else { - inputState.backStack + event.route + val backStack = when { + event.route.clearsBackStack() -> listOf(event.route) + event.popCurrent -> inputState.backStack.dropLast(1) + event.route + else -> inputState.backStack + event.route } return inputState.copy(backStack = backStack) } @@ -122,6 +123,15 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save } } + private fun applyNavigateBackToScreenEvent(inputState: RegistrationFlowState, event: RegistrationFlowEvent.NavigateBackToScreen): RegistrationFlowState { + val index = inputState.backStack.indexOfLast { it == event.route } + return if (index >= 0) { + inputState.copy(backStack = inputState.backStack.take(index + 1)) + } else { + inputState.copy(backStack = inputState.backStack.dropLast(1) + event.route) + } + } + /** * Validates a restored flow state by checking if the session is still valid. * @@ -177,6 +187,7 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save } } is RegistrationFlowEvent.NavigateBack, + is RegistrationFlowEvent.NavigateBackToScreen, is RegistrationFlowEvent.SessionUpdated, is RegistrationFlowEvent.E164Chosen, is RegistrationFlowEvent.RecoveryPasswordInvalid, 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 a2329422d7..1640a79c36 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 @@ -8,20 +8,26 @@ package org.signal.registration.screens.linkaccount import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.BoundsTransform import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -35,6 +41,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -55,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 kotlinx.coroutines.delay import org.signal.core.ui.WindowBreakpoint import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Buttons @@ -75,6 +83,10 @@ import org.signal.registration.screens.attachDebugLogHelper import org.signal.registration.screens.quickrestore.QrState import org.signal.registration.test.TestTags +private val OVERLAY_HORIZONTAL_PADDING = 24.dp +private const val EXPAND_BUTTON_FADE_DURATION_MS = 100 +private const val QR_MORPH_DURATION_MS = 300 + /** * Screen which will display a QR code for linking this device as a secondary. */ @@ -85,23 +97,38 @@ fun LinkAccountScreen( modifier: Modifier = Modifier ) { val layoutParams = RegistrationScaffold.rememberLayoutParams() + val isPhone = rememberWindowBreakpoint() is WindowBreakpoint.Small + + // Sequence the expand button animation with the QR morph + var expandButtonVisible by remember { mutableStateOf(!state.displayQrOverlay) } + LaunchedEffect(state.displayQrOverlay) { + if (state.displayQrOverlay) { + expandButtonVisible = false + } else { + delay(QR_MORPH_DURATION_MS.toLong()) + expandButtonVisible = true + } + } Surface(modifier = modifier.testTag(TestTags.LINK_ACCOUNT_SCREEN)) { SharedTransitionLayout { AnimatedContent( targetState = state.displayQrOverlay, - label = "qr_code_fullscreen_transition" + label = "qr_code_fullscreen_transition", + transitionSpec = { + fadeIn(animationSpec = tween(durationMillis = QR_MORPH_DURATION_MS, delayMillis = if (targetState) EXPAND_BUTTON_FADE_DURATION_MS else 0)) togetherWith fadeOut(tween(EXPAND_BUTTON_FADE_DURATION_MS)) + } ) { target -> CompositionLocalProvider( LocalSharedTransitionScope provides this@SharedTransitionLayout, LocalAnimateVisibilityScope provides this ) { if (target) { - QrCodeOverlay(state, onEvent) + QrCodeOverlay(state, onEvent, isPhone) } else { when (layoutParams) { - is RegistrationScaffold.Params.OnePane -> OnePane(layoutParams, state, onEvent) - is RegistrationScaffold.Params.TwoPane -> TwoPane(layoutParams, state, onEvent) + is RegistrationScaffold.Params.OnePane -> OnePane(layoutParams, isPhone, expandButtonVisible, state, onEvent) + is RegistrationScaffold.Params.TwoPane -> TwoPane(layoutParams, expandButtonVisible, state, onEvent) } } } @@ -139,6 +166,8 @@ private fun StateDialogs( @Composable private fun OnePane( params: RegistrationScaffold.Params.OnePane, + isPhone: Boolean, + expandButtonVisible: Boolean, state: LinkAccountScreenState, onEvent: (LinkAccountScreenEvent) -> Unit ) { @@ -149,16 +178,20 @@ private fun OnePane( content = { paddingValues -> Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = spacedBy(64.dp), + verticalArrangement = spacedBy(if (isPhone) 32.dp else 64.dp), modifier = Modifier .verticalScroll(scrollState) .padding(paddingValues) ) { Title() - QrCodeContent(state = state, onEvent = onEvent) + QrCodeContent(state = state, onEvent = onEvent, isPhone = isPhone, expandButtonVisible = expandButtonVisible) - Steps(verticalArrangement = spacedBy(32.dp), onEvent) + Steps( + verticalArrangement = spacedBy(if (isPhone) 20.dp else 32.dp), + centerGetHelp = isPhone, + onEvent = onEvent + ) } }, footer = { @@ -174,6 +207,7 @@ private fun OnePane( @Composable private fun TwoPane( params: RegistrationScaffold.Params.TwoPane, + expandButtonVisible: Boolean, state: LinkAccountScreenState, onEvent: (LinkAccountScreenEvent) -> Unit ) { @@ -193,7 +227,8 @@ private fun TwoPane( onEvent = onEvent, modifier = Modifier .weight(1f) - .padding(paddingValues) + .padding(paddingValues), + expandButtonVisible = expandButtonVisible ) }, footer = { @@ -217,7 +252,7 @@ private fun FirstPaneContent( ) { Title() - Steps(verticalArrangement = spacedBy(32.dp), onEvent = onEvent) + Steps(verticalArrangement = spacedBy(32.dp), centerGetHelp = false, onEvent = onEvent) } } @@ -235,6 +270,7 @@ private fun Title() { @Composable private fun Steps( verticalArrangement: Arrangement.Vertical, + centerGetHelp: Boolean, onEvent: (LinkAccountScreenEvent) -> Unit ) { Column(verticalArrangement = verticalArrangement) { @@ -253,17 +289,21 @@ private fun Steps( text = stringResource(R.string.LinkAccountScreen__tap_linked_devices_and_link_new_device) ) - GetHelp(onEvent) + GetHelp( + onEvent = onEvent, + modifier = if (centerGetHelp) Modifier.align(Alignment.CenterHorizontally) else Modifier + ) } } @Composable private fun GetHelp( - onEvent: (LinkAccountScreenEvent) -> Unit + onEvent: (LinkAccountScreenEvent) -> Unit, + modifier: Modifier = Modifier ) { TextButton( onClick = { onEvent(LinkAccountScreenEvent.GetHelpClick) }, - modifier = Modifier.testTag(TestTags.LINK_ACCOUNT_GET_HELP_BUTTON) + modifier = modifier.testTag(TestTags.LINK_ACCOUNT_GET_HELP_BUTTON) ) { Text(text = stringResource(R.string.LinkAccountScreen__get_help_with_these_steps)) } @@ -285,15 +325,25 @@ private fun Step(icon: ImageVector, text: String) { private fun QrCodeContent( state: LinkAccountScreenState, onEvent: (LinkAccountScreenEvent) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isPhone: Boolean = false, + isInOverlay: Boolean = false, + expandButtonVisible: Boolean = true, + overlayMaxWidth: Dp? = null ) { val sharedTransitionScope = LocalSharedTransitionScope.current!! val animatedVisibilityScope = LocalAnimateVisibilityScope.current!! + // Delay the morph on expand (so the button can fade out first) but not on collapse + val expanding = state.displayQrOverlay + val qrBoundsTransform = remember(expanding) { + BoundsTransform { _, _ -> + tween(durationMillis = QR_MORPH_DURATION_MS, delayMillis = if (expanding) EXPAND_BUTTON_FADE_DURATION_MS else 0) + } + } + Box( - contentAlignment = if (!state.displayQrOverlay) { - Alignment.CenterEnd - } else Alignment.Center, + contentAlignment = if (isInOverlay) Alignment.Center else Alignment.CenterEnd, modifier = modifier ) { with(sharedTransitionScope) { @@ -302,20 +352,22 @@ private fun QrCodeContent( modifier = Modifier .sharedElement( sharedContentState = rememberSharedContentState("qr_code_outer_border"), - animatedVisibilityScope = animatedVisibilityScope + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = qrBoundsTransform ) - .size(getQrOuterBorderSize(state.displayQrOverlay)) - .background(color = colorResource(org.signal.core.ui.R.color.signal_light_colorPrimary), shape = RoundedCornerShape(64.dp)) + .size(getQrOuterBorderSize(isInOverlay, overlayMaxWidth)) + .background(color = colorResource(org.signal.core.ui.R.color.signal_light_colorPrimary), shape = RoundedCornerShape(if (isPhone) 48.dp else 64.dp)) ) { AnimatedContent( targetState = state.qrCodeState, modifier = Modifier .sharedElement( sharedContentState = rememberSharedContentState("qr_code_inner_border"), - animatedVisibilityScope = animatedVisibilityScope + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = qrBoundsTransform ) - .size(getQrInnerBorderSize(state.displayQrOverlay)) - .background(color = Color.White, shape = RoundedCornerShape(24.dp)) + .size(getQrInnerBorderSize(isInOverlay, overlayMaxWidth)) + .background(color = Color.White, shape = RoundedCornerShape(if (isPhone) 26.dp else 24.dp)) ) { target -> Box( contentAlignment = Alignment.Center, @@ -323,7 +375,7 @@ private fun QrCodeContent( ) { when (target) { QrState.Failed -> QrCodeFailed(onEvent) - is QrState.Loaded -> QrCodeDisplay(target.qrCodeData, state.displayQrOverlay, sharedTransitionScope, animatedVisibilityScope) + is QrState.Loaded -> QrCodeDisplay(target.qrCodeData, isInOverlay, overlayMaxWidth, qrBoundsTransform, sharedTransitionScope, animatedVisibilityScope) QrState.Loading -> QrCodeLoading() QrState.Scanned -> QrCodeScanned() } @@ -333,14 +385,17 @@ private fun QrCodeContent( } AnimatedVisibility( - visible = state.qrCodeState is QrState.Loaded && !state.displayQrOverlay, - modifier = Modifier.align(Alignment.TopEnd), - enter = fadeIn(), - exit = fadeOut() + visible = state.qrCodeState is QrState.Loaded && !isInOverlay && expandButtonVisible, + modifier = Modifier + .align(Alignment.TopEnd) + .then(if (isPhone) Modifier.offset(x = 6.dp, y = (-6).dp) else Modifier) + .then(with(sharedTransitionScope) { Modifier.renderInSharedTransitionScopeOverlay(zIndexInOverlay = 1f) }), + enter = fadeIn(tween(EXPAND_BUTTON_FADE_DURATION_MS)), + exit = fadeOut(tween(EXPAND_BUTTON_FADE_DURATION_MS)) ) { IconButtons.IconButton( onClick = { onEvent(LinkAccountScreenEvent.DisplayOverlayClick) }, - size = 53.dp, + size = if (isPhone) 40.dp else 53.dp, colors = IconButtons.iconButtonColors( containerColor = Color(0xFF506DCD), contentColor = colorResource(org.signal.core.ui.R.color.signal_light_colorOnPrimary) @@ -360,6 +415,8 @@ private fun QrCodeContent( private fun QrCodeDisplay( qrCodeData: QrCodeData, isInOverlay: Boolean, + overlayMaxWidth: Dp?, + boundsTransform: BoundsTransform, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope ) { @@ -370,9 +427,10 @@ private fun QrCodeDisplay( modifier = Modifier .sharedElement( sharedContentState = rememberSharedContentState("qr_code_display"), - animatedVisibilityScope = animatedVisibilityScope + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = boundsTransform ) - .size(getQrCodeSize(isInOverlay)) + .size(getQrCodeSize(isInOverlay, overlayMaxWidth)) ) } } @@ -431,18 +489,26 @@ private fun QrCodeFailed( } @Composable -fun QrCodeOverlay( +private fun QrCodeOverlay( state: LinkAccountScreenState, - onEvent: (LinkAccountScreenEvent) -> Unit + onEvent: (LinkAccountScreenEvent) -> Unit, + isPhone: Boolean ) { Surface( modifier = Modifier.fillMaxSize() ) { - Box { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + ) { QrCodeContent( state = state, onEvent = onEvent, - modifier = Modifier.align(Alignment.Center) + modifier = Modifier.align(Alignment.Center), + isPhone = isPhone, + isInOverlay = true, + overlayMaxWidth = maxWidth ) IconButtons.IconButton( @@ -459,47 +525,52 @@ fun QrCodeOverlay( } @Composable -fun getQrOuterBorderSize(isInOverlay: Boolean): Dp { +private fun getQrOuterBorderSize(isInOverlay: Boolean, overlayMaxWidth: Dp? = null): Dp { if (isInOverlay) { - return 456.dp + return overlayOuterBorderSize(overlayMaxWidth) } val breakpoint = rememberWindowBreakpoint() return when (breakpoint) { - is WindowBreakpoint.Small -> 296.dp + is WindowBreakpoint.Small -> 272.dp is WindowBreakpoint.Medium -> 296.dp is WindowBreakpoint.Large -> 364.dp } } @Composable -fun getQrInnerBorderSize(isInOverlay: Boolean): Dp { +private fun getQrInnerBorderSize(isInOverlay: Boolean, overlayMaxWidth: Dp? = null): Dp { if (isInOverlay) { - return 360.dp + return overlayOuterBorderSize(overlayMaxWidth) * (360f / 456f) } val breakpoint = rememberWindowBreakpoint() return when (breakpoint) { - is WindowBreakpoint.Small -> 232.dp + is WindowBreakpoint.Small -> 208.dp is WindowBreakpoint.Medium -> 232.dp is WindowBreakpoint.Large -> 284.dp } } @Composable -fun getQrCodeSize(isInOverlay: Boolean): Dp { +private fun getQrCodeSize(isInOverlay: Boolean, overlayMaxWidth: Dp? = null): Dp { if (isInOverlay) { - return 297.dp + return overlayOuterBorderSize(overlayMaxWidth) * (297f / 456f) } val breakpoint = rememberWindowBreakpoint() return when (breakpoint) { - is WindowBreakpoint.Small -> 208.dp + is WindowBreakpoint.Small -> 176.dp is WindowBreakpoint.Medium -> 208.dp is WindowBreakpoint.Large -> 256.dp } } +private fun overlayOuterBorderSize(overlayMaxWidth: Dp?): Dp { + overlayMaxWidth ?: return 456.dp + return (overlayMaxWidth - OVERLAY_HORIZONTAL_PADDING * 2).coerceAtMost(456.dp) +} + @Composable private fun OnePaneFooterContent( params: RegistrationScaffold.Params.OnePane, 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 1f5fc27573..5d6055dd4e 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 @@ -65,7 +65,12 @@ class LinkAccountViewModel( val result = when (event) { LinkAccountScreenEvent.GetHelpClick -> error("This event is handled in the nav-entry.") LinkAccountScreenEvent.CreateAccountClick -> { - parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry)) + // Revisit permission screen if necessary + if (parentState.value.backStack.any { it == RegistrationRoute.PhoneNumberEntry }) { + parentEventEmitter(RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.PhoneNumberEntry)) + } else { + parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry), popCurrent = true) + } state } LinkAccountScreenEvent.DisplayOverlayClick -> state.copy(displayQrOverlay = true) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt index 2f3b227b4b..e163690559 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt @@ -228,7 +228,7 @@ private fun OnePaneLayout( OnePaneRegistrationScaffold( params = params, - topBar = { TopAppBar(scrollBehavior = topBarScrollBehavior) }, + topBar = { TopAppBar(scrollBehavior = topBarScrollBehavior, onEvent = onEvent) }, content = { paddingValues -> Column( modifier = Modifier @@ -289,7 +289,7 @@ private fun TwoPaneLayout( TwoPaneRegistrationScaffold( params = params, - topBar = { TopAppBar(scrollBehavior = topBarScrollBehavior) }, + topBar = { TopAppBar(scrollBehavior = topBarScrollBehavior, onEvent = onEvent) }, firstPane = { paddingValues -> Column( modifier = Modifier @@ -344,7 +344,8 @@ private fun TwoPaneLayout( @OptIn(ExperimentalMaterial3Api::class) @Composable fun TopAppBar( - scrollBehavior: TopAppBarScrollBehavior + scrollBehavior: TopAppBarScrollBehavior, + onEvent: (PhoneNumberEntryScreenEvents) -> Unit ) { val context = LocalContext.current @@ -382,7 +383,7 @@ fun TopAppBar( DropdownMenus.Item( text = { Text(text = stringResource(R.string.RegistrationActivity_link_device)) }, onClick = { - TODO("Handle link device") + onEvent(PhoneNumberEntryScreenEvents.LinkDevice) menuController.hide() } ) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt index 87fa8aea0d..efe3b51206 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt @@ -36,6 +36,10 @@ sealed class PhoneNumberEntryScreenEvents { /** The user requested to open the country picker. */ data object CountryPicker : PhoneNumberEntryScreenEvents() + + /** The user chose to link this device to an existing account instead of registering a new number. */ + data object LinkDevice : PhoneNumberEntryScreenEvents() + data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents() { override fun toString(): String = "CaptchaCompleted(token=${token.censor()})" } 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 c686e62227..4995b56b78 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 @@ -132,6 +132,9 @@ class PhoneNumberEntryViewModel( ) } } + is PhoneNumberEntryScreenEvents.LinkDevice -> { + parentEventEmitter.navigateTo(RegistrationRoute.LinkAccount) + } is PhoneNumberEntryScreenEvents.CaptchaCompleted -> { stateEmitter(applyCaptchaCompleted(state, event.token, parentEventEmitter)) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/util/EmitterExtensions.kt b/feature/registration/src/main/java/org/signal/registration/screens/util/EmitterExtensions.kt index 3d98525646..4ef53fe932 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/util/EmitterExtensions.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/util/EmitterExtensions.kt @@ -11,8 +11,8 @@ import org.signal.registration.RegistrationRoute /** * Convenience function to emit a navigation event to a parentEmitter. */ -fun ((RegistrationFlowEvent) -> Unit).navigateTo(route: RegistrationRoute) { - this(RegistrationFlowEvent.NavigateToScreen(route)) +fun ((RegistrationFlowEvent) -> Unit).navigateTo(route: RegistrationRoute, popCurrent: Boolean = false) { + this(RegistrationFlowEvent.NavigateToScreen(route, popCurrent)) } /** 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 33c2323033..199f851b99 100644 --- a/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelTest.kt @@ -459,6 +459,68 @@ class RegistrationViewModelTest { assertThat(result.backStack).isEqualTo(listOf(RegistrationRoute.Welcome, preRegisterSelection)) } + @Test + fun `applyEvent NavigateToScreen with popCurrent replaces the current screen`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val initialState = RegistrationFlowState( + backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry, RegistrationRoute.LinkAccount) + ) + + val result = viewModel.applyEvent( + initialState, + RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry), popCurrent = true) + ) + + assertThat(result.backStack).isEqualTo( + listOf( + RegistrationRoute.Welcome, + RegistrationRoute.PhoneNumberEntry, + RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry) + ) + ) + } + + @Test + fun `applyEvent NavigateBackToScreen pops back to the target screen`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val initialState = RegistrationFlowState( + backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry, RegistrationRoute.LinkAccount) + ) + + val result = viewModel.applyEvent(initialState, RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.PhoneNumberEntry)) + + assertThat(result.backStack).isEqualTo(listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry)) + } + + @Test + fun `applyEvent NavigateBackToScreen replaces the current screen when the route is not on the back stack`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val initialState = RegistrationFlowState( + backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.LinkAccount) + ) + + val result = viewModel.applyEvent(initialState, RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.PhoneNumberEntry)) + + assertThat(result.backStack).isEqualTo( + listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry) + ) + } + @Test fun `applyEvent ResetState returns default state`() = runTest(testDispatcher) { coEvery { mockRepository.restoreFlowState() } returns null 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 96755aba97..9821cd8a1f 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 @@ -66,10 +66,10 @@ class LinkAccountViewModelTest { Dispatchers.resetMain() } - private fun TestScope.createViewModel(): LinkAccountViewModel { + private fun TestScope.createViewModel(parentState: RegistrationFlowState = RegistrationFlowState()): LinkAccountViewModel { val viewModel = LinkAccountViewModel( repository = mockRepository, - parentState = MutableStateFlow(RegistrationFlowState()), + parentState = MutableStateFlow(parentState), parentEventEmitter = parentEventEmitter ) // Keep the WhileSubscribed state flow hot so state.value reflects updates during the test. @@ -211,13 +211,38 @@ class LinkAccountViewModelTest { } @Test - fun `applyEvent CreateAccountClick navigates to Permissions`() = runTest(testDispatcher) { - val viewModel = createViewModel() + fun `applyEvent CreateAccountClick from link-device-first flow routes through Permissions`() = runTest(testDispatcher) { + val viewModel = createViewModel( + RegistrationFlowState(backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.LinkAccount)) + ) viewModel.applyEvent(LinkAccountScreenState(), LinkAccountScreenEvent.CreateAccountClick, stateEmitter) assertThat(emittedParentEvents).contains( - RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry)) + RegistrationFlowEvent.NavigateToScreen( + route = RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry), + popCurrent = true + ) + ) + } + + @Test + fun `applyEvent CreateAccountClick from phone number screen returns to it without Permissions`() = runTest(testDispatcher) { + val viewModel = createViewModel( + RegistrationFlowState( + backStack = listOf( + RegistrationRoute.Welcome, + RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry), + RegistrationRoute.PhoneNumberEntry, + RegistrationRoute.LinkAccount + ) + ) + ) + + viewModel.applyEvent(LinkAccountScreenState(), LinkAccountScreenEvent.CreateAccountClick, stateEmitter) + + assertThat(emittedParentEvents).contains( + RegistrationFlowEvent.NavigateBackToScreen(RegistrationRoute.PhoneNumberEntry) ) } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt index a54ded0570..281a08eb8a 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt @@ -249,6 +249,22 @@ class PhoneNumberEntryViewModelTest { ) } + @Test + fun `LinkDevice navigates to the link account flow`() = runTest { + viewModel.applyEvent( + PhoneNumberEntryState(), + PhoneNumberEntryScreenEvents.LinkDevice, + parentEventEmitter, + stateEmitter + ) + + assertThat(emittedEvents).hasSize(1) + assertThat(emittedEvents.first()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + } + @Test fun `ConsumeInnerOneTimeEvent clears inner event`() = runTest { val initialState = PhoneNumberEntryState(