mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Add signal login screen scaffolding.
This commit is contained in:
@@ -109,6 +109,9 @@ import org.signal.registration.screens.restoreselection.ArchiveRestoreOption
|
||||
import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionScreen
|
||||
import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionViewModel
|
||||
import org.signal.registration.screens.restoreselection.RegisteredState
|
||||
import org.signal.registration.screens.signallogin.SignalLoginScreen
|
||||
import org.signal.registration.screens.signallogin.SignalLoginScreenActions
|
||||
import org.signal.registration.screens.signallogin.SignalLoginViewModel
|
||||
import org.signal.registration.screens.signallogininfo.SignalLoginInfoScreen
|
||||
import org.signal.registration.screens.signallogininfo.SignalLoginInfoViewModel
|
||||
import org.signal.registration.screens.signalloginpayment.SignalLoginPaymentScreen
|
||||
@@ -165,6 +168,10 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
|
||||
@Serializable
|
||||
data object SignalLoginInfo : RegistrationRoute
|
||||
|
||||
/** Log in with the account key of a Signal Login the user already owns. */
|
||||
@Serializable
|
||||
data object SignalLogin : RegistrationRoute
|
||||
|
||||
/** Optional username selection for a phone-numberless account. */
|
||||
@Serializable
|
||||
data object AddUsername : RegistrationRoute
|
||||
@@ -665,6 +672,28 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
)
|
||||
}
|
||||
|
||||
// -- Signal Login Screen
|
||||
entry<RegistrationRoute.SignalLogin> {
|
||||
val viewModel: SignalLoginViewModel = viewModel(
|
||||
factory = SignalLoginViewModel.Factory(
|
||||
repository = registrationRepository,
|
||||
parentEventEmitter = registrationViewModel::onEvent
|
||||
)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
CollectActions(viewModel.actions) { action ->
|
||||
when (action) {
|
||||
SignalLoginScreenActions.OpenNeedHelpArticle -> openUrl(context, SIGNAL_LOGIN_LEARN_MORE_URL)
|
||||
}
|
||||
}
|
||||
|
||||
SignalLoginScreen(
|
||||
state = state,
|
||||
onEvent = { viewModel.onEvent(it) }
|
||||
)
|
||||
}
|
||||
|
||||
// -- Add Username Screen
|
||||
entry<RegistrationRoute.AddUsername> {
|
||||
val viewModel: AddUsernameViewModel = viewModel(
|
||||
|
||||
+421
@@ -0,0 +1,421 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogin
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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.registration.R
|
||||
import org.signal.registration.fonts.MonoTypeface
|
||||
import org.signal.registration.screens.OnePaneRegistrationScaffold
|
||||
import org.signal.registration.screens.RegistrationScaffold
|
||||
import org.signal.registration.screens.TwoPaneRegistrationScaffold
|
||||
import org.signal.registration.screens.attachDebugLogHelper
|
||||
import org.signal.registration.screens.shared.BackTopAppBar
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
/**
|
||||
* Logs an existing Signal Login in by asking for its account key.
|
||||
*/
|
||||
@Composable
|
||||
fun SignalLoginScreen(
|
||||
state: SignalLoginState,
|
||||
onEvent: (SignalLoginScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val simpleError: Pair<String, SignalLoginScreenEvents>? = when {
|
||||
state.dialogs.networkError -> stringResource(R.string.VerificationCodeScreen__network_error) to SignalLoginScreenEvents.NetworkErrorDialogDismissed
|
||||
state.dialogs.unknownError -> stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred) to SignalLoginScreenEvents.UnknownErrorDialogDismissed
|
||||
else -> null
|
||||
}
|
||||
|
||||
simpleError?.let { (message, dismissedEvent) ->
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = message,
|
||||
dismiss = stringResource(android.R.string.ok),
|
||||
onDismiss = { onEvent(dismissedEvent) }
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTag(TestTags.SIGNAL_LOGIN_SCREEN)
|
||||
) {
|
||||
when (val params = RegistrationScaffold.rememberLayoutParams()) {
|
||||
is RegistrationScaffold.Params.OnePane -> OnePaneLayout(params, state, onEvent)
|
||||
is RegistrationScaffold.Params.TwoPane -> TwoPaneLayout(params, state, onEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun OnePaneLayout(
|
||||
params: RegistrationScaffold.Params.OnePane,
|
||||
state: SignalLoginState,
|
||||
onEvent: (SignalLoginScreenEvents) -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
|
||||
|
||||
OnePaneRegistrationScaffold(
|
||||
params = params,
|
||||
topBar = { BackTopAppBar(scrollBehavior = topBarScrollBehavior, onBackClick = { onEvent(SignalLoginScreenEvents.BackClicked) }) },
|
||||
content = { paddingValues ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Header()
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
AccountKeyTextField(state = state, onEvent = onEvent)
|
||||
}
|
||||
},
|
||||
footer = { Footer(params, state, scrollState.canScrollForward, onEvent) }
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TwoPaneLayout(
|
||||
params: RegistrationScaffold.Params.TwoPane,
|
||||
state: SignalLoginState,
|
||||
onEvent: (SignalLoginScreenEvents) -> Unit
|
||||
) {
|
||||
val firstPaneScrollState = rememberScrollState()
|
||||
val secondPaneScrollState = rememberScrollState()
|
||||
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
|
||||
|
||||
TwoPaneRegistrationScaffold(
|
||||
params = params,
|
||||
topBar = { BackTopAppBar(scrollBehavior = topBarScrollBehavior, onBackClick = { onEvent(SignalLoginScreenEvents.BackClicked) }) },
|
||||
firstPane = { paddingValues ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(firstPaneScrollState)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Header(twoPane = true)
|
||||
}
|
||||
},
|
||||
secondPane = { paddingValues ->
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(secondPaneScrollState)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
AccountKeyTextField(state = state, onEvent = onEvent)
|
||||
}
|
||||
},
|
||||
footer = { Footer(params, state, firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward, onEvent) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(twoPane: Boolean = false) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_signal_login_ring),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.SignalLoginScreen__signal_login),
|
||||
style = if (twoPane) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.attachDebugLogHelper()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.SignalLoginScreen__enter_your_32_character_account_key),
|
||||
style = if (twoPane) MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal) else MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountKeyTextField(
|
||||
state: SignalLoginState,
|
||||
onEvent: (SignalLoginScreenEvents) -> Unit
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var requestFocus by remember { mutableStateOf(true) }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
TextField(
|
||||
value = state.accountKey,
|
||||
onValueChange = { onEvent(SignalLoginScreenEvents.AccountKeyChanged(it)) },
|
||||
label = { Text(stringResource(R.string.SignalLoginScreen__account_key)) },
|
||||
enabled = !state.isSubmitting,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontFamily = MonoTypeface.fontFamily(),
|
||||
fontSize = 18.sp,
|
||||
letterSpacing = 1.44.sp
|
||||
),
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
errorContainerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = {
|
||||
if (state.isNextEnabled) {
|
||||
keyboardController?.hide()
|
||||
onEvent(SignalLoginScreenEvents.NextClicked)
|
||||
}
|
||||
}
|
||||
),
|
||||
supportingText = {
|
||||
when (val error = state.accountKeyError) {
|
||||
is AccountKeyError.TooLong -> Text(stringResource(R.string.SignalLoginScreen__too_long, error.count, SignalLoginState.ACCOUNT_KEY_LENGTH))
|
||||
is AccountKeyError.Invalid -> Text(stringResource(R.string.SignalLoginScreen__invalid_account_key))
|
||||
is AccountKeyError.Incorrect -> Text(stringResource(R.string.SignalLoginScreen__incorrect_account_key))
|
||||
null -> {}
|
||||
}
|
||||
},
|
||||
isError = state.accountKeyError != null,
|
||||
visualTransformation = AccountKeyVisualTransformation,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.SIGNAL_LOGIN_ACCOUNT_KEY_FIELD)
|
||||
.focusRequester(focusRequester)
|
||||
.onGloballyPositioned {
|
||||
if (requestFocus) {
|
||||
focusRequester.requestFocus()
|
||||
requestFocus = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Footer(
|
||||
params: RegistrationScaffold.Params,
|
||||
state: SignalLoginState,
|
||||
isElevated: Boolean,
|
||||
onEvent: (SignalLoginScreenEvents) -> Unit
|
||||
) {
|
||||
RegistrationScaffold.FooterSurface(isElevated = isElevated) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(params.footerPadding)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
NeedHelpButton(onEvent)
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
NextButton(state, onEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NeedHelpButton(onEvent: (SignalLoginScreenEvents) -> Unit) {
|
||||
TextButton(
|
||||
shape = RoundedCornerShape(0.dp),
|
||||
onClick = { onEvent(SignalLoginScreenEvents.NeedHelpClicked) },
|
||||
modifier = Modifier.testTag(TestTags.SIGNAL_LOGIN_NEED_HELP_BUTTON)
|
||||
) {
|
||||
Text(text = stringResource(R.string.SignalLoginScreen__need_help))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NextButton(state: SignalLoginState, onEvent: (SignalLoginScreenEvents) -> Unit) {
|
||||
Buttons.LargeTonal(
|
||||
enabled = state.isNextEnabled,
|
||||
onClick = { onEvent(SignalLoginScreenEvents.NextClicked) },
|
||||
modifier = Modifier.testTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON)
|
||||
) {
|
||||
if (state.isSubmitting) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 3.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
} else {
|
||||
Text(text = stringResource(R.string.SignalLoginScreen__next))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an account key the way its ACI is normally written: uppercased and split into 8-4-4-4-12 groups by dashes.
|
||||
* The dashes are display-only, so what the view model sees is always the unformatted key.
|
||||
*/
|
||||
internal object AccountKeyVisualTransformation : VisualTransformation {
|
||||
|
||||
/** Offsets in the raw key that a dash is inserted in front of. */
|
||||
private val DASH_OFFSETS = intArrayOf(8, 12, 16, 20)
|
||||
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
val transformed = buildString {
|
||||
for ((index, character) in text.text.withIndex()) {
|
||||
if (index in DASH_OFFSETS) {
|
||||
append('-')
|
||||
}
|
||||
append(character.uppercaseChar())
|
||||
}
|
||||
}
|
||||
|
||||
return TransformedText(
|
||||
text = AnnotatedString(transformed),
|
||||
offsetMapping = AccountKeyOffsetMapping(text.length)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A dash is only present if the key is long enough to have a character after it, so [inputLength] decides which of
|
||||
* [DASH_OFFSETS] actually made it into the transformed text.
|
||||
*/
|
||||
private class AccountKeyOffsetMapping(private val inputLength: Int) : OffsetMapping {
|
||||
override fun originalToTransformed(offset: Int): Int = offset + DASH_OFFSETS.count { it <= offset && it < inputLength }
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int = offset - DASH_OFFSETS.withIndex().count { (index, dashOffset) -> dashOffset < inputLength && dashOffset + index < offset }
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun SignalLoginScreenPreview() {
|
||||
Previews.Preview {
|
||||
SignalLoginScreen(
|
||||
state = SignalLoginState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun SignalLoginScreenFilledPreview() {
|
||||
Previews.Preview {
|
||||
SignalLoginScreen(
|
||||
state = SignalLoginState(accountKey = "a6b284822e3283d07f2391360a4c2b91"),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun SignalLoginScreenSubmittingPreview() {
|
||||
Previews.Preview {
|
||||
SignalLoginScreen(
|
||||
state = SignalLoginState(
|
||||
accountKey = "a6b284822e3283d07f2391360a4c2b91",
|
||||
isSubmitting = true
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun SignalLoginScreenErrorPreview() {
|
||||
Previews.Preview {
|
||||
SignalLoginScreen(
|
||||
state = SignalLoginState(
|
||||
accountKey = "a6b284822e3283d07f2391360a4c2b91",
|
||||
accountKeyError = AccountKeyError.Incorrect
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogin
|
||||
|
||||
sealed interface SignalLoginScreenActions {
|
||||
/** Open the article explaining where to find your account key. */
|
||||
data object OpenNeedHelpArticle : SignalLoginScreenActions
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogin
|
||||
|
||||
import org.signal.core.util.censor
|
||||
|
||||
sealed class SignalLoginScreenEvents {
|
||||
/** The user tapped the back arrow. */
|
||||
data object BackClicked : SignalLoginScreenEvents()
|
||||
|
||||
/** The user edited the account key field. Carries the raw text, formatting and all. */
|
||||
data class AccountKeyChanged(val value: String) : SignalLoginScreenEvents() {
|
||||
override fun toString(): String = "AccountKeyChanged(value=${value.censor()})"
|
||||
}
|
||||
|
||||
/** The user tapped "Need help?". */
|
||||
data object NeedHelpClicked : SignalLoginScreenEvents()
|
||||
|
||||
/** The user submitted the account key, either with the next button or the keyboard's next action. */
|
||||
data object NextClicked : SignalLoginScreenEvents()
|
||||
|
||||
/** The user dismissed the network error dialog. */
|
||||
data object NetworkErrorDialogDismissed : SignalLoginScreenEvents()
|
||||
|
||||
/** The user dismissed the unknown error dialog. */
|
||||
data object UnknownErrorDialogDismissed : SignalLoginScreenEvents()
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogin
|
||||
|
||||
import org.signal.core.util.censor
|
||||
|
||||
/**
|
||||
* State for the screen where a user who already owns a Signal Login types in their account key to log in.
|
||||
*
|
||||
* [accountKey] holds the key without any of the formatting the user sees: the screen renders the dashes and
|
||||
* uppercasing itself, so what is stored here is always the raw lowercase value.
|
||||
*/
|
||||
data class SignalLoginState(
|
||||
val accountKey: String = "",
|
||||
val accountKeyError: AccountKeyError? = null,
|
||||
val isSubmitting: Boolean = false,
|
||||
val dialogs: Dialogs = Dialogs()
|
||||
) {
|
||||
|
||||
/** Whether the entered key is complete and well-formed enough to send to the service. */
|
||||
val isNextEnabled: Boolean
|
||||
get() = accountKey.length == ACCOUNT_KEY_LENGTH && accountKeyError == null && !isSubmitting
|
||||
|
||||
override fun toString(): String = "SignalLoginState(accountKey=${accountKey.censor()}, accountKeyError=$accountKeyError, isSubmitting=$isSubmitting, dialogs=$dialogs)"
|
||||
|
||||
data class Dialogs(
|
||||
val networkError: Boolean = false,
|
||||
val unknownError: Boolean = false
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** An account key is an ACI with its dashes removed, so it is always this many hex characters. */
|
||||
const val ACCOUNT_KEY_LENGTH = 32
|
||||
}
|
||||
}
|
||||
|
||||
/** Why the entered account key can't be submitted. Shown beneath the text field rather than in a dialog. */
|
||||
sealed interface AccountKeyError {
|
||||
/** More than [SignalLoginState.ACCOUNT_KEY_LENGTH] characters were entered. */
|
||||
data class TooLong(val count: Int) : AccountKeyError
|
||||
|
||||
/** The entered text contains characters that can't appear in an account key. */
|
||||
data object Invalid : AccountKeyError
|
||||
|
||||
/** The service didn't recognize the entered account key. */
|
||||
data object Incorrect : AccountKeyError
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogin
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationRepository
|
||||
import org.signal.registration.screens.util.navigateBack
|
||||
|
||||
/**
|
||||
* View model for [SignalLoginScreen].
|
||||
*
|
||||
* Logging in with an account key requires an endpoint that doesn't exist yet, so [SignalLoginScreenEvents.NextClicked]
|
||||
* is deliberately left as a stub. Everything the screen needs to validate and format what the user types is here.
|
||||
*/
|
||||
class SignalLoginViewModel(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
) : EventDrivenViewModel<SignalLoginScreenEvents>(TAG) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(SignalLoginViewModel::class)
|
||||
|
||||
/** Formatting the user may have pasted along with the key, which we accept and discard. */
|
||||
private val FORMATTING_CHARACTERS = Regex("""[\s-]""")
|
||||
|
||||
private fun Char.isAccountKeyCharacter(): Boolean = this in '0'..'9' || this in 'a'..'f'
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(SignalLoginState())
|
||||
val state: StateFlow<SignalLoginState> = _state.asStateFlow()
|
||||
|
||||
private val _actions = Channel<SignalLoginScreenActions>(Channel.BUFFERED)
|
||||
val actions: Flow<SignalLoginScreenActions> = _actions.receiveAsFlow()
|
||||
|
||||
init {
|
||||
_state
|
||||
.onEach { Log.d(TAG, "[State] $it") }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override suspend fun processEvent(event: SignalLoginScreenEvents) {
|
||||
applyEvent(_state.value, event, parentEventEmitter) { _state.value = it }
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
suspend fun applyEvent(
|
||||
state: SignalLoginState,
|
||||
event: SignalLoginScreenEvents,
|
||||
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||
stateEmitter: (SignalLoginState) -> Unit
|
||||
) {
|
||||
when (event) {
|
||||
is SignalLoginScreenEvents.BackClicked -> {
|
||||
parentEventEmitter.navigateBack()
|
||||
}
|
||||
|
||||
is SignalLoginScreenEvents.AccountKeyChanged -> {
|
||||
val accountKey = event.value.replace(FORMATTING_CHARACTERS, "").lowercase()
|
||||
stateEmitter(state.copy(accountKey = accountKey, accountKeyError = validate(accountKey)))
|
||||
}
|
||||
|
||||
is SignalLoginScreenEvents.NeedHelpClicked -> {
|
||||
_actions.trySend(SignalLoginScreenActions.OpenNeedHelpArticle)
|
||||
}
|
||||
|
||||
is SignalLoginScreenEvents.NextClicked -> {
|
||||
Log.i(TAG, "Next clicked, but logging in with an account key isn't implemented yet.")
|
||||
}
|
||||
|
||||
is SignalLoginScreenEvents.NetworkErrorDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(networkError = false)))
|
||||
}
|
||||
|
||||
is SignalLoginScreenEvents.UnknownErrorDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(unknownError = false)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks an already-normalized [accountKey]. A key that is merely incomplete isn't an error — the next button stays
|
||||
* disabled until it is the right length, without nagging the user as they type.
|
||||
*/
|
||||
private fun validate(accountKey: String): AccountKeyError? {
|
||||
return when {
|
||||
accountKey.length > SignalLoginState.ACCOUNT_KEY_LENGTH -> AccountKeyError.TooLong(accountKey.length)
|
||||
accountKey.any { !it.isAccountKeyCharacter() } -> AccountKeyError.Invalid
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return SignalLoginViewModel(repository, parentEventEmitter) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -21,7 +21,9 @@ import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationRepository
|
||||
import org.signal.registration.RegistrationRoute
|
||||
import org.signal.registration.screens.util.navigateBack
|
||||
import org.signal.registration.screens.util.navigateTo
|
||||
|
||||
class SignalLoginPaymentViewModel(
|
||||
private val repository: RegistrationRepository,
|
||||
@@ -75,8 +77,12 @@ class SignalLoginPaymentViewModel(
|
||||
}
|
||||
|
||||
is SignalLoginPaymentScreenEvents.ContinueClicked -> {
|
||||
// TODO [phonenumberless] Launch the purchase flow, or navigate to account key entry for an existing login.
|
||||
Log.i(TAG, "Continue clicked for ${state.selectedOption}, but the flow isn't implemented yet.")
|
||||
if (state.selectedOption == SignalLoginPaymentState.Option.ExistingLogin) {
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.SignalLogin)
|
||||
} else {
|
||||
// TODO [phonenumberless] Launch the purchase flow.
|
||||
Log.i(TAG, "Continue clicked for ${state.selectedOption}, but the purchase flow isn't implemented yet.")
|
||||
}
|
||||
}
|
||||
|
||||
is SignalLoginPaymentScreenEvents.NetworkErrorDialogDismissed -> {
|
||||
|
||||
@@ -56,6 +56,12 @@ object TestTags {
|
||||
const val SIGNAL_LOGIN_PAYMENT_EXISTING_LOGIN_OPTION = "signal_login_payment_existing_login_option"
|
||||
const val SIGNAL_LOGIN_PAYMENT_CONTINUE_BUTTON = "signal_login_payment_continue_button"
|
||||
|
||||
// Signal Login Screen
|
||||
const val SIGNAL_LOGIN_SCREEN = "signal_login_screen"
|
||||
const val SIGNAL_LOGIN_ACCOUNT_KEY_FIELD = "signal_login_account_key_field"
|
||||
const val SIGNAL_LOGIN_NEED_HELP_BUTTON = "signal_login_need_help_button"
|
||||
const val SIGNAL_LOGIN_NEXT_BUTTON = "signal_login_next_button"
|
||||
|
||||
// Signal Login Info Screen
|
||||
const val SIGNAL_LOGIN_INFO_SCREEN = "signal_login_info_screen"
|
||||
const val SIGNAL_LOGIN_INFO_CREDENTIAL_CARD = "signal_login_info_credential_card"
|
||||
|
||||
@@ -652,6 +652,24 @@
|
||||
<!-- Error shown when the Signal Login could not be stored in the password manager. -->
|
||||
<string name="SignalLoginInfoScreen__your_signal_login_could_not_be_saved">Your Signal Login could not be saved. Please save it manually instead.</string>
|
||||
|
||||
<!-- Signal Login account key entry screen -->
|
||||
<!-- Title of the screen where a user with an existing Signal Login types in their account key. -->
|
||||
<string name="SignalLoginScreen__signal_login">Signal Login</string>
|
||||
<!-- Description telling the user what to enter to log in. -->
|
||||
<string name="SignalLoginScreen__enter_your_32_character_account_key">Enter your 32-character account key to get started.</string>
|
||||
<!-- Label of the text field the account key is typed into. -->
|
||||
<string name="SignalLoginScreen__account_key">Account key</string>
|
||||
<!-- Button that opens a support article for users who can\'t find their account key. -->
|
||||
<string name="SignalLoginScreen__need_help">Need help?</string>
|
||||
<!-- Action button that submits the entered account key. -->
|
||||
<string name="SignalLoginScreen__next">Next</string>
|
||||
<!-- Error shown when too many characters were entered. First placeholder is how many were entered, second is the maximum. -->
|
||||
<string name="SignalLoginScreen__too_long">Too long. %1$d/%2$d characters.</string>
|
||||
<!-- Error shown when the entered account key contains characters that can\'t appear in one. -->
|
||||
<string name="SignalLoginScreen__invalid_account_key">Invalid account key</string>
|
||||
<!-- Error shown when the service doesn\'t recognize the entered account key. -->
|
||||
<string name="SignalLoginScreen__incorrect_account_key">Incorrect account key</string>
|
||||
|
||||
<!-- Add username screen -->
|
||||
<!-- Title of the screen offering the user an optional username. -->
|
||||
<string name="AddUsernameScreen__add_an_optional_username">Add an optional username</string>
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogin
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import org.junit.Test
|
||||
|
||||
class AccountKeyVisualTransformationTest {
|
||||
|
||||
companion object {
|
||||
private const val FULL_KEY = "a6b284822e3283d07f2391360a4c2b91"
|
||||
private const val FULL_KEY_FORMATTED = "A6B28482-2E32-83D0-7F23-91360A4C2B91"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full key is uppercased and split into UUID groups`() {
|
||||
assertThat(transform(FULL_KEY)).isEqualTo(FULL_KEY_FORMATTED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty key transforms to nothing`() {
|
||||
assertThat(transform("")).isEqualTo("")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no trailing dash is added to a key that ends on a group boundary`() {
|
||||
assertThat(transform("a6b28482")).isEqualTo("A6B28482")
|
||||
assertThat(transform("a6b284822e32")).isEqualTo("A6B28482-2E32")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dash appears as soon as the next group is started`() {
|
||||
assertThat(transform("a6b284822")).isEqualTo("A6B28482-2")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every cursor position maps into the transformed text and back`() {
|
||||
for (length in 0..FULL_KEY.length) {
|
||||
val key = FULL_KEY.take(length)
|
||||
val mapping = AccountKeyVisualTransformation.filter(AnnotatedString(key)).offsetMapping
|
||||
val transformedLength = transform(key).length
|
||||
|
||||
for (offset in 0..length) {
|
||||
val transformed = mapping.originalToTransformed(offset)
|
||||
|
||||
assertThat(transformed in 0..transformedLength).isEqualTo(true)
|
||||
assertThat(mapping.transformedToOriginal(transformed)).isEqualTo(offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun transform(text: String): String = AccountKeyVisualTransformation.filter(AnnotatedString(text)).text.text
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogin
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.signal.core.ui.CoreUiDependenciesRule
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class SignalLoginScreenTest {
|
||||
|
||||
companion object {
|
||||
private const val VALID_ACCOUNT_KEY = "a6b284822e3283d07f2391360a4c2b91"
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@get:Rule
|
||||
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
|
||||
|
||||
private val events = mutableListOf<SignalLoginScreenEvents>()
|
||||
|
||||
@Test
|
||||
fun `when text is typed into the account key field, AccountKeyChanged is emitted`() {
|
||||
setContent(SignalLoginState())
|
||||
|
||||
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_ACCOUNT_KEY_FIELD).performTextInput("a6b2")
|
||||
|
||||
assertThat(events).contains(SignalLoginScreenEvents.AccountKeyChanged("a6b2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Need help is clicked, NeedHelpClicked is emitted`() {
|
||||
setContent(SignalLoginState())
|
||||
|
||||
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEED_HELP_BUTTON).performClick()
|
||||
|
||||
assertThat(events).contains(SignalLoginScreenEvents.NeedHelpClicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Next is clicked with a complete key, NextClicked is emitted`() {
|
||||
setContent(SignalLoginState(accountKey = VALID_ACCOUNT_KEY))
|
||||
|
||||
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON).performClick()
|
||||
|
||||
assertThat(events).contains(SignalLoginScreenEvents.NextClicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given an incomplete key, Next is disabled`() {
|
||||
setContent(SignalLoginState(accountKey = "a6b28482"))
|
||||
|
||||
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given a complete key, Next is enabled`() {
|
||||
setContent(SignalLoginState(accountKey = VALID_ACCOUNT_KEY))
|
||||
|
||||
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON).assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given a submission is in flight, Next is disabled`() {
|
||||
setContent(SignalLoginState(accountKey = VALID_ACCOUNT_KEY, isSubmitting = true))
|
||||
|
||||
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
private fun setContent(state: SignalLoginState) {
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
SignalLoginScreen(
|
||||
state = state,
|
||||
onEvent = { events += it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogin
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationRepository
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SignalLoginViewModelTest {
|
||||
|
||||
companion object {
|
||||
private const val VALID_ACCOUNT_KEY = "a6b284822e3283d07f2391360a4c2b91"
|
||||
}
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
private lateinit var mockRepository: RegistrationRepository
|
||||
private lateinit var viewModel: SignalLoginViewModel
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
mockRepository = mockk(relaxed = true)
|
||||
viewModel = SignalLoginViewModel(repository = mockRepository, parentEventEmitter = {})
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClicked navigates back`() = runTest(testDispatcher) {
|
||||
val parentEvents = mutableListOf<RegistrationFlowEvent>()
|
||||
|
||||
viewModel.applyEvent(SignalLoginState(), SignalLoginScreenEvents.BackClicked, { parentEvents.add(it) }) {}
|
||||
|
||||
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AccountKeyChanged strips formatting and lowercases the entered key`() = runTest(testDispatcher) {
|
||||
val state = applyAccountKey("A6B28482-2E32-83D0-7F23 91360A4C2B91")
|
||||
|
||||
assertThat(state.accountKey).isEqualTo(VALID_ACCOUNT_KEY)
|
||||
assertThat(state.accountKeyError).isNull()
|
||||
assertThat(state.isNextEnabled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AccountKeyChanged does not report an error for a partially typed key`() = runTest(testDispatcher) {
|
||||
val state = applyAccountKey("a6b28482")
|
||||
|
||||
assertThat(state.accountKeyError).isNull()
|
||||
assertThat(state.isNextEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AccountKeyChanged reports non-hex characters as invalid`() = runTest(testDispatcher) {
|
||||
val state = applyAccountKey(VALID_ACCOUNT_KEY.dropLast(1) + "z")
|
||||
|
||||
assertThat(state.accountKeyError).isEqualTo(AccountKeyError.Invalid)
|
||||
assertThat(state.isNextEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AccountKeyChanged reports an over-long key as too long`() = runTest(testDispatcher) {
|
||||
val state = applyAccountKey(VALID_ACCOUNT_KEY + "ab")
|
||||
|
||||
assertThat(state.accountKeyError).isEqualTo(AccountKeyError.TooLong(34))
|
||||
assertThat(state.isNextEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NeedHelpClicked opens the help article`() = runTest(testDispatcher) {
|
||||
val actions = mutableListOf<SignalLoginScreenActions>()
|
||||
backgroundScope.launch { viewModel.actions.toList(actions) }
|
||||
|
||||
viewModel.applyEvent(SignalLoginState(), SignalLoginScreenEvents.NeedHelpClicked, {}) {}
|
||||
|
||||
assertThat(actions).containsExactly(SignalLoginScreenActions.OpenNeedHelpArticle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NextClicked does nothing yet because logging in is not implemented`() = runTest(testDispatcher) {
|
||||
val parentEvents = mutableListOf<RegistrationFlowEvent>()
|
||||
val states = mutableListOf<SignalLoginState>()
|
||||
|
||||
viewModel.applyEvent(SignalLoginState(accountKey = VALID_ACCOUNT_KEY), SignalLoginScreenEvents.NextClicked, { parentEvents.add(it) }) { states.add(it) }
|
||||
|
||||
assertThat(parentEvents).isEmpty()
|
||||
assertThat(states).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NetworkErrorDialogDismissed clears the dialog`() = runTest(testDispatcher) {
|
||||
var state: SignalLoginState? = null
|
||||
|
||||
viewModel.applyEvent(
|
||||
SignalLoginState(dialogs = SignalLoginState.Dialogs(networkError = true)),
|
||||
SignalLoginScreenEvents.NetworkErrorDialogDismissed,
|
||||
{}
|
||||
) { state = it }
|
||||
|
||||
assertThat(state!!.dialogs.networkError).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UnknownErrorDialogDismissed clears the dialog`() = runTest(testDispatcher) {
|
||||
var state: SignalLoginState? = null
|
||||
|
||||
viewModel.applyEvent(
|
||||
SignalLoginState(dialogs = SignalLoginState.Dialogs(unknownError = true)),
|
||||
SignalLoginScreenEvents.UnknownErrorDialogDismissed,
|
||||
{}
|
||||
) { state = it }
|
||||
|
||||
assertThat(state!!.dialogs.unknownError).isFalse()
|
||||
}
|
||||
|
||||
private suspend fun applyAccountKey(value: String): SignalLoginState {
|
||||
var state = SignalLoginState()
|
||||
viewModel.applyEvent(state, SignalLoginScreenEvents.AccountKeyChanged(value), {}) { state = it }
|
||||
return state
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user