From 8c67592ca9abb2df107fea57224a1df706936c57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Tue, 12 Sep 2023 17:07:06 +0200 Subject: [PATCH] Migrate login screen to compose Convert login screen to compose Fixes theme colors Adds AccountToken to Model Adds visual transformation --- .../compose/screen/AccountScreen.kt | 152 ++++---- .../compose/screen/ConnectScreen.kt | 9 +- .../mullvadvpn/compose/screen/LoginScreen.kt | 351 ++++++++++++++++++ .../compose/screen/SelectLocationScreen.kt | 9 +- .../compose/screen/SplitTunnelingScreen.kt | 8 +- .../mullvadvpn/compose/state/LoginUiState.kt | 34 ++ .../util/AccountTokenVisualTransformation.kt | 26 ++ .../repository/AccountRepository.kt | 30 +- .../mullvadvpn/ui/fragment/LoginFragment.kt | 300 +++------------ .../mullvadvpn/viewmodel/LoginViewModel.kt | 152 ++++---- .../viewmodel/LoginViewModelTest.kt | 220 ++++++----- .../mullvad/mullvadvpn/model/AccountToken.kt | 3 + .../net/mullvad/mullvadvpn/lib/theme/Theme.kt | 29 +- .../lib/theme/dimensions/Dimensions.kt | 8 +- 14 files changed, 783 insertions(+), 548 deletions(-) create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/AccountTokenVisualTransformation.kt create mode 100644 android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountToken.kt diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt index cf8af6fc0736..f48da58208f5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -111,57 +112,74 @@ fun AccountScreen( val scrollState = rememberScrollState() - Column( - verticalArrangement = Arrangement.Bottom, - horizontalAlignment = Alignment.Start, - modifier = - Modifier.background(MaterialTheme.colorScheme.background) - .fillMaxSize() - .drawVerticalScrollbar(scrollState) - .verticalScroll(scrollState) - .animateContentSize() - ) { - Text( - style = MaterialTheme.typography.labelMedium, - text = stringResource(id = R.string.device_name), - modifier = Modifier.padding(start = Dimens.sideMargin, end = Dimens.sideMargin) - ) + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column( + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.Start, + modifier = + Modifier.fillMaxSize() + .drawVerticalScrollbar(scrollState) + .verticalScroll(scrollState) + .animateContentSize() + ) { + Text( + style = MaterialTheme.typography.labelMedium, + text = stringResource(id = R.string.device_name), + modifier = Modifier.padding(start = Dimens.sideMargin, end = Dimens.sideMargin) + ) - InformationView( - content = uiState.deviceName.capitalizeFirstCharOfEachWord(), - whenMissing = MissingPolicy.SHOW_SPINNER - ) + InformationView( + content = uiState.deviceName.capitalizeFirstCharOfEachWord(), + whenMissing = MissingPolicy.SHOW_SPINNER + ) - Text( - style = MaterialTheme.typography.labelMedium, - text = stringResource(id = R.string.account_number), - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - top = Dimens.smallPadding - ) - ) + Text( + style = MaterialTheme.typography.labelMedium, + text = stringResource(id = R.string.account_number), + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + top = Dimens.smallPadding + ) + ) - CopyableObfuscationView(content = uiState.accountNumber) + CopyableObfuscationView(content = uiState.accountNumber) - Text( - style = MaterialTheme.typography.labelMedium, - text = stringResource(id = R.string.paid_until), - modifier = Modifier.padding(start = Dimens.sideMargin, end = Dimens.sideMargin) - ) + Text( + style = MaterialTheme.typography.labelMedium, + text = stringResource(id = R.string.paid_until), + modifier = Modifier.padding(start = Dimens.sideMargin, end = Dimens.sideMargin) + ) - InformationView( - content = uiState.accountExpiry?.toExpiryDateString() ?: "", - whenMissing = MissingPolicy.SHOW_SPINNER - ) + InformationView( + content = uiState.accountExpiry?.toExpiryDateString() ?: "", + whenMissing = MissingPolicy.SHOW_SPINNER + ) + + Spacer(modifier = Modifier.weight(1f)) - Spacer(modifier = Modifier.weight(1f)) + if (BuildConfig.BUILD_TYPE != BuildTypes.RELEASE) { + ActionButton( + text = stringResource(id = R.string.manage_account), + onClick = onManageAccountClick, + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.screenVerticalMargin + ), + colors = + ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } - if (BuildConfig.BUILD_TYPE != BuildTypes.RELEASE) { ActionButton( - text = stringResource(id = R.string.manage_account), - onClick = onManageAccountClick, + text = stringResource(id = R.string.redeem_voucher), + onClick = onRedeemVoucherClick, modifier = Modifier.padding( start = Dimens.sideMargin, @@ -174,39 +192,23 @@ fun AccountScreen( containerColor = MaterialTheme.colorScheme.surface ) ) - } - - ActionButton( - text = stringResource(id = R.string.redeem_voucher), - onClick = onRedeemVoucherClick, - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.screenVerticalMargin - ), - colors = - ButtonDefaults.buttonColors( - contentColor = MaterialTheme.colorScheme.onPrimary, - containerColor = MaterialTheme.colorScheme.surface - ) - ) - ActionButton( - text = stringResource(id = R.string.log_out), - onClick = onLogoutClick, - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.screenVerticalMargin - ), - colors = - ButtonDefaults.buttonColors( - contentColor = MaterialTheme.colorScheme.onPrimary, - containerColor = MaterialTheme.colorScheme.error - ) - ) + ActionButton( + text = stringResource(id = R.string.log_out), + onClick = onLogoutClick, + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.screenVerticalMargin + ), + colors = + ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.error + ) + ) + } } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index d250a5467eaa..a3f885f623d8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -161,12 +162,10 @@ fun ConnectScreen( end = Dimens.sideMargin, top = Dimens.mediumPadding ) - .size( - width = Dimens.progressIndicatorSize, - height = Dimens.progressIndicatorSize - ) + .size(Dimens.progressIndicatorSize) .align(Alignment.CenterHorizontally) - .testTag(CIRCULAR_PROGRESS_INDICATOR) + .testTag(CIRCULAR_PROGRESS_INDICATOR), + strokeCap = StrokeCap.Round ) } Spacer(modifier = Modifier.height(Dimens.mediumPadding)) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt new file mode 100644 index 000000000000..11f8ce328596 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt @@ -0,0 +1,351 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.window.PopupProperties +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.ActionButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.state.LoginError +import net.mullvad.mullvadvpn.compose.state.LoginState +import net.mullvad.mullvadvpn.compose.state.LoginState.* +import net.mullvad.mullvadvpn.compose.state.LoginUiState +import net.mullvad.mullvadvpn.compose.util.accountTokenVisualTransformation +import net.mullvad.mullvadvpn.lib.theme.AlphaTopBar +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Preview +@Composable +private fun PreviewIdle() { + AppTheme { LoginScreen(state = LoginUiState()) } +} + +@Preview +@Composable +private fun PreviewLoggingIn() { + AppTheme { LoginScreen(state = LoginUiState(loginState = Loading.LoggingIn)) } +} + +@Preview +@Composable +private fun PreviewCreatingAccount() { + AppTheme { LoginScreen(state = LoginUiState(loginState = Loading.CreatingAccount)) } +} + +@Preview +@Composable +private fun PreviewLoginError() { + AppTheme { LoginScreen(state = LoginUiState(loginState = Idle(LoginError.InvalidCredentials))) } +} + +@Preview +@Composable +private fun PreviewLoginSuccess() { + AppTheme { LoginScreen(state = LoginUiState(loginState = Success)) } +} + +@Composable +fun LoginScreen( + state: LoginUiState, + initialAccountNumber: String = "", + onLoginClick: (String) -> Unit = {}, + onCreateAccountClick: () -> Unit = {}, + onDeleteHistoryClick: () -> Unit = {}, + onSettingsClick: () -> Unit = {}, +) { + ScaffoldWithTopBar( + topBarColor = MaterialTheme.colorScheme.primary, + statusBarColor = MaterialTheme.colorScheme.primary, + navigationBarColor = MaterialTheme.colorScheme.background, + iconTintColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar), + onSettingsClicked = onSettingsClick, + onAccountClicked = null + ) { + Column(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.primary)) { + Spacer(modifier = Modifier.weight(1f)) + LoginIcon( + state.loginState, + modifier = + Modifier.align(Alignment.CenterHorizontally) + .padding(bottom = Dimens.largePadding) + ) + LoginContent(state, initialAccountNumber, onLoginClick, onDeleteHistoryClick) + Spacer(modifier = Modifier.weight(3f)) + CreateAccountPanel(onCreateAccountClick, isEnabled = state.loginState is Idle) + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun LoginContent( + state: LoginUiState, + initialAccountNumber: String, + onLoginClick: (String) -> Unit, + onDeleteHistoryClick: () -> Unit +) { + Column( + modifier = + Modifier.animateContentSize().fillMaxWidth().padding(horizontal = Dimens.sideMargin) + ) { + Text( + text = state.loginState.title(), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.fillMaxWidth().padding(bottom = Dimens.mediumPadding) + ) + + var accountNumber by remember { mutableStateOf(initialAccountNumber) } + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + TextField( + modifier = Modifier.menuAnchor().fillMaxWidth(), + value = accountNumber, + label = { + Text( + text = stringResource(id = R.string.login_description), + color = Color.Unspecified + ) + }, + keyboardActions = KeyboardActions(onDone = { onLoginClick(accountNumber) }), + keyboardOptions = + KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number), + onValueChange = { accountNumber = it }, + singleLine = true, + maxLines = 1, + visualTransformation = accountTokenVisualTransformation(), + enabled = state.loginState is Idle, + colors = + TextFieldDefaults.colors( + focusedTextColor = Color.Black, + unfocusedTextColor = Color.Gray, + disabledTextColor = Color.Gray, + errorTextColor = Color.Black, + cursorColor = MaterialTheme.colorScheme.background, + focusedPlaceholderColor = MaterialTheme.colorScheme.background, + unfocusedPlaceholderColor = MaterialTheme.colorScheme.primary, + focusedLabelColor = MaterialTheme.colorScheme.background, + disabledLabelColor = Color.Gray, + unfocusedLabelColor = Color.Gray, + focusedLeadingIconColor = Color.Black, + unfocusedSupportingTextColor = Color.Black, + ), + isError = state.loginState.isError() + ) + + // If we have a previous account, show dropdown for quick re-login + state.lastUsedAccount?.let { token -> + DropdownMenu( + modifier = + Modifier.background(MaterialTheme.colorScheme.background) + .exposedDropdownSize(true), + expanded = expanded, + properties = PopupProperties(focusable = false), + onDismissRequest = { expanded = false } + ) { + val accountTransformation = remember { accountTokenVisualTransformation() } + val transformedText = + remember(token.value) { + accountTransformation.filter(AnnotatedString(token.value)).text + } + + AccountDropDownItem( + accountToken = transformedText.toString(), + onClick = { + accountNumber = token.value + expanded = false + onLoginClick(accountNumber) + } + ) { + onDeleteHistoryClick() + } + } + } + } + + Text( + text = state.loginState.supportingText() ?: "", + style = MaterialTheme.typography.labelMedium, + color = + if (state.loginState.isError()) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onPrimary + }, + modifier = Modifier.fillMaxWidth().padding(horizontal = Dimens.mediumPadding) + ) + + Spacer(modifier = Modifier.size(Dimens.smallPadding)) + ActionButton( + isEnabled = state.loginState is Idle, + onClick = { onLoginClick(accountNumber) }, + colors = + ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.surface + ), + text = stringResource(id = R.string.login_title) + ) + } +} + +@Composable +private fun LoginIcon(state: LoginState, modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(Dimens.loginIconContainerSize) + ) { + when (state) { + is Idle -> + if (state.loginError != null) { + Image( + painter = painterResource(id = R.drawable.icon_fail), + contentDescription = null, + contentScale = ContentScale.Inside + ) + } + is Loading -> + CircularProgressIndicator( + modifier = Modifier.size(Dimens.progressIndicatorSize), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = Dimens.loadingSpinnerStrokeWidth, + strokeCap = StrokeCap.Round + ) + Success -> + Image( + modifier = Modifier.offset(-Dimens.smallPadding, -Dimens.smallPadding), + painter = painterResource(id = R.drawable.icon_success), + contentDescription = null, + ) + } + } +} + +@Composable +private fun LoginState.title(): String = + stringResource( + id = + when (this) { + is Idle -> + when (this.loginError) { + is LoginError -> R.string.login_fail_title + null -> R.string.login_title + } + is Loading -> R.string.logging_in_title + Success -> R.string.logged_in_title + } + ) + +@Composable +private fun LoginState.supportingText(): String? { + val res = + when (this) { + is Idle -> { + when (loginError) { + LoginError.InvalidCredentials -> R.string.login_fail_description + LoginError.UnableToCreateAccount -> R.string.failed_to_create_account + is LoginError.Unknown -> R.string.error_occurred + null -> return null + } + } + is Loading.CreatingAccount -> R.string.creating_new_account + is Loading.LoggingIn, + Success -> R.string.logging_in_description + } + return stringResource(id = res) +} + +@Composable +private fun AccountDropDownItem( + accountToken: String, + onClick: () -> Unit, + onDeleteClick: () -> Unit +) { + Row( + modifier = Modifier.clickable(onClick = onClick), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = + Modifier.weight(1f) + .padding(horizontal = Dimens.smallPadding, vertical = Dimens.smallPadding), + text = accountToken + ) + IconButton(onClick = onDeleteClick) { + Icon( + painter = painterResource(id = R.drawable.account_history_remove_pressed), + contentDescription = null, + modifier = Modifier.size(Dimens.listIconSize), + tint = Color.Unspecified + ) + } + } +} + +@Composable +private fun CreateAccountPanel(onCreateAccountClick: () -> Unit, isEnabled: Boolean) { + Column( + Modifier.fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = Dimens.sideMargin, vertical = Dimens.screenVerticalMargin), + ) { + Text( + modifier = Modifier.padding(bottom = Dimens.smallPadding), + text = stringResource(id = R.string.dont_have_an_account), + color = MaterialTheme.colorScheme.onPrimary, + ) + ActionButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.create_account), + isEnabled = isEnabled, + colors = + ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.primary + ), + onClick = onCreateAccountClick + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt index 3376ffe422e6..eec8300a8cee 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -143,11 +144,9 @@ fun SelectLocationScreen( CircularProgressIndicator( color = MaterialTheme.colorScheme.onBackground, modifier = - Modifier.size( - width = Dimens.progressIndicatorSize, - height = Dimens.progressIndicatorSize - ) - .testTag(CIRCULAR_PROGRESS_INDICATOR) + Modifier.size(Dimens.progressIndicatorSize) + .testTag(CIRCULAR_PROGRESS_INDICATOR), + strokeCap = StrokeCap.Round ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt index 5170ef0845fa..ca561b30ffe6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -134,11 +135,8 @@ fun SplitTunnelingScreen( item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) { CircularProgressIndicator( color = MaterialTheme.colorScheme.onBackground, - modifier = - Modifier.size( - width = Dimens.progressIndicatorSize, - height = Dimens.progressIndicatorSize - ) + modifier = Modifier.size(Dimens.progressIndicatorSize), + strokeCap = StrokeCap.Round ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt new file mode 100644 index 000000000000..6a667552e17a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt @@ -0,0 +1,34 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.model.AccountToken + +data class LoginUiState( + val lastUsedAccount: AccountToken? = null, + val loginState: LoginState = LoginState.Idle(null) +) { + companion object { + val INITIAL = LoginUiState() + } +} + +sealed interface LoginState { + fun isError() = this is Idle && loginError != null + + data class Idle(val loginError: LoginError? = null) : LoginState + + sealed interface Loading : LoginState { + data object LoggingIn : Loading + + data object CreatingAccount : Loading + } + + data object Success : LoginState +} + +sealed class LoginError { + data object UnableToCreateAccount : LoginError() + + data object InvalidCredentials : LoginError() + + data class Unknown(val reason: String) : LoginError() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/AccountTokenVisualTransformation.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/AccountTokenVisualTransformation.kt new file mode 100644 index 000000000000..2e294e48e435 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/AccountTokenVisualTransformation.kt @@ -0,0 +1,26 @@ +package net.mullvad.mullvadvpn.compose.util + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +const val ACCOUNT_TOKEN_SEPARATOR = " " +const val ACCOUNT_TOKEN_CHUNK_SIZE = 4 + +fun accountTokenVisualTransformation() = VisualTransformation { + val transformedString = + it.chunked(ACCOUNT_TOKEN_CHUNK_SIZE).joinToString(ACCOUNT_TOKEN_SEPARATOR) + val transformedAnnotatedString = AnnotatedString(transformedString) + + TransformedText( + transformedAnnotatedString, + object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = + offset + (offset - 1) / ACCOUNT_TOKEN_CHUNK_SIZE + + override fun transformedToOriginal(offset: Int): Int = + offset - offset / ACCOUNT_TOKEN_CHUNK_SIZE + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt index ddcde5640771..de72fa8d93db 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt @@ -3,32 +3,36 @@ package net.mullvad.mullvadvpn.repository import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext import net.mullvad.mullvadvpn.lib.ipc.Event import net.mullvad.mullvadvpn.model.AccountCreationResult import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.model.AccountHistory +import net.mullvad.mullvadvpn.model.LoginResult import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.accountDataSource import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault class AccountRepository( private val serviceConnectionManager: ServiceConnectionManager, - dispatcher: CoroutineDispatcher = Dispatchers.IO + val dispatcher: CoroutineDispatcher = Dispatchers.IO ) { private val _cachedCreatedAccount = MutableStateFlow(null) val cachedCreatedAccount = _cachedCreatedAccount.asStateFlow() - val accountCreationEvents: SharedFlow = + private val accountCreationEvents: SharedFlow = serviceConnectionManager.connectionState .flatMapReadyConnectionOrDefault(flowOf()) { state -> state.container.accountDataSource.accountCreationResult @@ -49,7 +53,7 @@ class AccountRepository( AccountExpiry.Missing ) - val accountHistoryEvents: StateFlow = + val accountHistory: StateFlow = serviceConnectionManager.connectionState .flatMapReadyConnectionOrDefault(flowOf()) { state -> state.container.accountDataSource.accountHistory @@ -61,20 +65,26 @@ class AccountRepository( AccountHistory.Missing ) - val loginEvents: SharedFlow = + private val loginEvents: SharedFlow = serviceConnectionManager.connectionState .flatMapReadyConnectionOrDefault(flowOf()) { state -> state.container.accountDataSource.loginEvents } .shareIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed()) - fun createAccount() { - serviceConnectionManager.accountDataSource()?.createAccount() - } + suspend fun createAccount(): AccountCreationResult = + withContext(dispatcher) { + val deferred = async { accountCreationEvents.first() } + serviceConnectionManager.accountDataSource()?.createAccount() + deferred.await() + } - fun login(accountToken: String) { - serviceConnectionManager.accountDataSource()?.login(accountToken) - } + suspend fun login(accountToken: String): LoginResult = + withContext(Dispatchers.IO) { + val deferred = async { loginEvents.first().result } + serviceConnectionManager.accountDataSource()?.login(accountToken) + deferred.await() + } fun logout() { clearCreatedAccountCache() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt index 7e298e3f7392..5e7287ff75a3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt @@ -1,198 +1,77 @@ package net.mullvad.mullvadvpn.ui.fragment -import android.graphics.Rect import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ScrollView -import android.widget.TextView -import androidx.core.content.ContextCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.lib.common.util.JobTracker -import net.mullvad.mullvadvpn.ui.LoginState +import net.mullvad.mullvadvpn.compose.screen.LoginScreen +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.model.AccountToken +import net.mullvad.mullvadvpn.ui.MainActivity import net.mullvad.mullvadvpn.ui.NavigationBarPainter -import net.mullvad.mullvadvpn.ui.extension.requireMainActivity -import net.mullvad.mullvadvpn.ui.paintNavigationBar -import net.mullvad.mullvadvpn.ui.widget.AccountInput -import net.mullvad.mullvadvpn.ui.widget.AccountLogin -import net.mullvad.mullvadvpn.ui.widget.Button -import net.mullvad.mullvadvpn.ui.widget.HeaderBar +import net.mullvad.mullvadvpn.viewmodel.LoginSideEffect import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import org.koin.androidx.viewmodel.ext.android.viewModel class LoginFragment : BaseFragment(), NavigationBarPainter { - - private val loginViewModel: LoginViewModel by viewModel() - - private lateinit var title: TextView - private lateinit var subtitle: TextView - private lateinit var loggingInStatus: View - private lateinit var loggedInStatus: View - private lateinit var loginFailStatus: View - private lateinit var accountLogin: AccountLogin - private lateinit var scrollArea: ScrollView - private lateinit var background: View - private lateinit var headerBar: HeaderBar - private lateinit var input: AccountInput - private lateinit var createAccountButton: Button - - @Deprecated("Refactor code to instead rely on Lifecycle.") private val jobTracker = JobTracker() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycleScope.launchUiSubscriptionsOnResume() - } + private val vm: LoginViewModel by viewModel() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - val view = inflater.inflate(R.layout.login, container, false) - - headerBar = - view.findViewById(R.id.header_bar).apply { - setAccountButtonVisibility(false) - } - - title = view.findViewById(R.id.title) - subtitle = view.findViewById(R.id.subtitle) - loggingInStatus = view.findViewById(R.id.logging_in_status) - loggedInStatus = view.findViewById(R.id.logged_in_status) - loginFailStatus = view.findViewById(R.id.login_fail_status) - - accountLogin = - view.findViewById(R.id.account_login).apply { - onLogin = loginViewModel::login - onClearHistory = loginViewModel::clearAccountHistory - } - createAccountButton = view.findViewById(R.id.create_account) - createAccountButton.setOnClickAction( - "createAccount", - jobTracker, - loginViewModel::createAccount - ) - - scrollArea = view.findViewById(R.id.scroll_area) - - background = - view.findViewById(R.id.contents).apply { setOnClickListener { requestFocus() } } - - scrollToShow(accountLogin) - - loginViewModel.clearState() - triggerAutoLoginIfAccountTokenPresent() - input = accountLogin.findViewById(R.id.input) - return view - } - - override fun onStart() { - super.onStart() - requireMainActivity().backButtonHandler = { - if (accountLogin.hasFocus) { - background.requestFocus() - true + // TODO: Remove this when we have a better solution for login after clearing max devices + val accountTokenArgument = arguments?.getString(ACCOUNT_TOKEN_ARGUMENT_KEY) + val initialAccountNumber = + if (accountTokenArgument != null) { + // Login and set initial TextField value + vm.login(accountTokenArgument) + accountTokenArgument } else { - false - } - } - input.onTextChanged.subscribe(this) { createAccountButton.isEnabled = it.isEmpty() } - } - - override fun onResume() { - super.onResume() - paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue)) - } - - override fun onStop() { - jobTracker.cancelAllJobs() - requireMainActivity().backButtonHandler = null - input.onTextChanged.unsubscribe(this) - super.onStop() - } - - private fun triggerAutoLoginIfAccountTokenPresent() { - arguments?.getString(ACCOUNT_TOKEN_ARGUMENT_KEY)?.also { accountToken -> - accountLogin.setAccountToken(accountToken) - loginViewModel.login(accountToken) - } - } - - private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch { - repeatOnLifecycle(Lifecycle.State.RESUMED) { - lanuchUpdateAccountHistory() - launchUpdateUiOnViewModelStateChanges() - } - } - - private fun CoroutineScope.lanuchUpdateAccountHistory() = launch { - loginViewModel.accountHistory.collect { history -> - accountLogin.accountHistory = history.accountToken() - } - } - - private fun CoroutineScope.launchUpdateUiOnViewModelStateChanges() = launch { - loginViewModel.uiState - .onEach { - // Adds a short delay to prevent loading spinner flickering. - if (it.isLoading().not()) { - delay(MINIMUM_LOADING_SPINNER_TIME_MILLIS) + "" + } + + return inflater.inflate(R.layout.fragment_compose, container, false).apply { + findViewById(R.id.compose_view).setContent { + AppTheme { + val loginUiState by vm.uiState.collectAsState() + LaunchedEffect(Unit) { + vm.sideEffect.collect { + when (it) { + LoginSideEffect.NavigateToWelcome, + LoginSideEffect + .NavigateToConnect -> {} // TODO Fix when we redo navigation + is LoginSideEffect.TooManyDevices -> { + navigateToDeviceListFragment(it.accountToken) + } + } + } + } + LoginScreen( + loginUiState, + initialAccountNumber, + vm::login, + vm::createAccount, + vm::clearAccountHistory, + ::openSettingsView + ) } } - .collect { uiState -> updateUi(uiState) } - } - - private fun updateUi(uiState: LoginViewModel.LoginUiState) { - when (uiState) { - is LoginViewModel.LoginUiState.Default -> { - showDefault() - } - is LoginViewModel.LoginUiState.Success -> { - // MainActivity responsible for transition to connect/out-of-time view. - showLoggedIn() - } - is LoginViewModel.LoginUiState.AccountCreated -> { - // MainActivity responsible for transition to welcome view. - } - is LoginViewModel.LoginUiState.CreatingAccount -> { - showCreatingAccount() - } - is LoginViewModel.LoginUiState.Loading -> { - showLoading() - } - is LoginViewModel.LoginUiState.InvalidAccountError -> { - loginFailure(resources.getString(R.string.login_fail_description)) - } - is LoginViewModel.LoginUiState.TooManyDevicesError -> { - showLoading(overrideSpinnerWithErrorIcon = true) - openDeviceListFragment(uiState.accountToken) - } - is LoginViewModel.LoginUiState.TooManyDevicesMissingListError -> { - loginFailure(context?.getString(R.string.failed_to_fetch_devices)) - } - is LoginViewModel.LoginUiState.UnableToCreateAccountError -> { - loginFailure(resources.getString(R.string.failed_to_create_account)) - } - is LoginViewModel.LoginUiState.OtherError -> { - loginFailure(resources.getString(R.string.error_occurred)) - } } } - private fun openDeviceListFragment(accountToken: String) { - + private fun navigateToDeviceListFragment(accountToken: AccountToken) { val deviceFragment = DeviceListFragment().apply { - arguments = Bundle().apply { putString(ACCOUNT_TOKEN_ARGUMENT_KEY, accountToken) } + arguments = + Bundle().apply { putString(ACCOUNT_TOKEN_ARGUMENT_KEY, accountToken.value) } } parentFragmentManager.beginTransaction().apply { @@ -208,88 +87,7 @@ class LoginFragment : BaseFragment(), NavigationBarPainter { } } - private fun showDefault() { - accountLogin.state = LoginState.Initial - headerBar.tunnelState = null - paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue)) - } - - private fun showLoading(overrideSpinnerWithErrorIcon: Boolean = false) { - accountLogin.state = LoginState.InProgress - headerBar.setSettingsButtonEnabled(false) - - title.setText(R.string.logging_in_title) - subtitle.setText(R.string.logging_in_description) - - loggingInStatus.visibility = - if (overrideSpinnerWithErrorIcon == false) { - View.VISIBLE - } else { - View.GONE - } - - loginFailStatus.visibility = - if (overrideSpinnerWithErrorIcon == false) { - View.GONE - } else { - View.VISIBLE - } - - loggedInStatus.visibility = View.GONE - - background.requestFocus() - - scrollToShow(loggingInStatus) - } - - private fun showLoggedIn() { - title.setText(R.string.logged_in_title) - subtitle.setText(R.string.logged_in_description) - - loggingInStatus.visibility = View.GONE - loginFailStatus.visibility = View.GONE - loggedInStatus.visibility = View.VISIBLE - - accountLogin.state = LoginState.Success - headerBar.setSettingsButtonEnabled(false) - - scrollToShow(loggedInStatus) - } - - private fun showCreatingAccount() { - title.setText(R.string.logging_in_title) - subtitle.setText(R.string.creating_new_account) - - loggingInStatus.visibility = View.VISIBLE - loginFailStatus.visibility = View.GONE - loggedInStatus.visibility = View.GONE - - accountLogin.state = LoginState.InProgress - headerBar.setSettingsButtonEnabled(true) - - scrollToShow(loggingInStatus) - } - - private fun loginFailure(description: String? = "") { - title.setText(R.string.login_fail_title) - subtitle.text = description - - loggingInStatus.visibility = View.GONE - loginFailStatus.visibility = View.VISIBLE - loggedInStatus.visibility = View.GONE - - accountLogin.state = LoginState.Failure - headerBar.setSettingsButtonEnabled(true) - - scrollToShow(accountLogin) - } - - private fun scrollToShow(view: View) { - val rectangle = Rect(0, 0, view.width, view.height) - scrollArea.requestChildRectangleOnScreen(view, rectangle, false) - } - - companion object { - private const val MINIMUM_LOADING_SPINNER_TIME_MILLIS = 200L + private fun openSettingsView() { + (context as? MainActivity)?.openSettings() } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt index 07e8f487053f..7a848121cee9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt @@ -4,109 +4,117 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.state.LoginError +import net.mullvad.mullvadvpn.compose.state.LoginState +import net.mullvad.mullvadvpn.compose.state.LoginState.* +import net.mullvad.mullvadvpn.compose.state.LoginUiState import net.mullvad.mullvadvpn.model.AccountCreationResult +import net.mullvad.mullvadvpn.model.AccountToken import net.mullvad.mullvadvpn.model.LoginResult import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository +private const val MINIMUM_LOADING_SPINNER_TIME_MILLIS = 500L + +sealed interface LoginSideEffect { + data object NavigateToWelcome : LoginSideEffect + + data object NavigateToConnect : LoginSideEffect + + data class TooManyDevices(val accountToken: AccountToken) : LoginSideEffect +} + class LoginViewModel( private val accountRepository: AccountRepository, private val deviceRepository: DeviceRepository, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { - private val _uiState = MutableStateFlow(LoginUiState.Default) - val uiState: StateFlow = _uiState - - val accountHistory = accountRepository.accountHistoryEvents - - sealed class LoginUiState { - data object Default : LoginUiState() - - data object Loading : LoginUiState() - - data class Success(val isOutOfTime: Boolean) : LoginUiState() - - data object CreatingAccount : LoginUiState() - - data object AccountCreated : LoginUiState() - - data object UnableToCreateAccountError : LoginUiState() + private val _loginState = MutableStateFlow(Idle(null)) - data object InvalidAccountError : LoginUiState() + private val _sideEffect = MutableSharedFlow(extraBufferCapacity = 1) + val sideEffect = _sideEffect.asSharedFlow() - data class TooManyDevicesError(val accountToken: String) : LoginUiState() - - data object TooManyDevicesMissingListError : LoginUiState() - - data class OtherError(val errorMessage: String) : LoginUiState() - - fun isLoading(): Boolean { - return this is Loading + private val _uiState = + combine( + accountRepository.accountHistory, + _loginState, + ) { accountHistoryState, loginState -> + LoginUiState(accountHistoryState.accountToken()?.let(::AccountToken), loginState) } - } + val uiState: StateFlow = + _uiState.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), LoginUiState.INITIAL) fun clearAccountHistory() = accountRepository.clearAccountHistory() - fun clearState() { - _uiState.value = LoginUiState.Default - } - fun createAccount() { - _uiState.value = LoginUiState.CreatingAccount + _loginState.value = Loading.CreatingAccount viewModelScope.launch(dispatcher) { - _uiState.value = - accountRepository.accountCreationEvents - .onStart { accountRepository.createAccount() } - .first() - .mapToUiState() + accountRepository.createAccount().mapToUiState()?.let { _loginState.value = it } } } fun login(accountToken: String) { - _uiState.value = LoginUiState.Loading + _loginState.value = Loading.LoggingIn viewModelScope.launch(dispatcher) { - _uiState.value = - accountRepository.loginEvents - .onStart { accountRepository.login(accountToken) } - .map { it.result.mapToUiState(accountToken) } - .first() + // Ensure we always take at least MINIMUM_LOADING_SPINNER_TIME_MILLIS to show the + // loading indicator + val loginDeferred = async { accountRepository.login(accountToken) } + delay(MINIMUM_LOADING_SPINNER_TIME_MILLIS) + + val uiState = + when (val result = loginDeferred.await()) { + LoginResult.Ok -> { + launch { + delay(1000) + _sideEffect.emit(LoginSideEffect.NavigateToConnect) + } + Success + } + LoginResult.InvalidAccount -> Idle(LoginError.InvalidCredentials) + LoginResult.MaxDevicesReached -> { + // TODO this refresh process should be handled by DeviceListScreen. + val refreshResult = + deviceRepository.refreshAndAwaitDeviceListWithTimeout( + accountToken = accountToken, + shouldClearCache = true, + shouldOverrideCache = true, + timeoutMillis = 5000L + ) + + if (refreshResult.isAvailable()) { + // Navigate to device list + _sideEffect.emit( + LoginSideEffect.TooManyDevices(AccountToken(accountToken)) + ) + return@launch + } else { + // Failed to fetch devices list + Idle(LoginError.Unknown(result.toString())) + } + } + else -> Idle(LoginError.Unknown(result.toString())) + } + _loginState.update { uiState } } } - private fun AccountCreationResult.mapToUiState(): LoginUiState { + private suspend fun AccountCreationResult.mapToUiState(): LoginState? { return if (this is AccountCreationResult.Success) { - LoginUiState.AccountCreated + _sideEffect.emit(LoginSideEffect.NavigateToWelcome) + null } else { - LoginUiState.UnableToCreateAccountError - } - } - - private suspend fun LoginResult.mapToUiState(accountToken: String): LoginUiState { - return when (this) { - LoginResult.Ok -> LoginUiState.Success(false) - LoginResult.InvalidAccount -> LoginUiState.InvalidAccountError - LoginResult.MaxDevicesReached -> { - val refreshResult = - deviceRepository.refreshAndAwaitDeviceListWithTimeout( - accountToken = accountToken, - shouldClearCache = true, - shouldOverrideCache = true, - timeoutMillis = 5000L - ) - - if (refreshResult.isAvailable()) { - LoginUiState.TooManyDevicesError(accountToken) - } else { - LoginUiState.TooManyDevicesMissingListError - } - } - else -> LoginUiState.OtherError(errorMessage = this.toString()) + Idle(LoginError.UnableToCreateAccount) } } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt index 4ba80511c63f..eb1a64ff130f 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt @@ -2,58 +2,44 @@ package net.mullvad.mullvadvpn.viewmodel import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test +import app.cash.turbine.turbineScope import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify -import junit.framework.Assert.assertEquals import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import net.mullvad.mullvadvpn.lib.ipc.Event +import net.mullvad.mullvadvpn.compose.state.LoginError +import net.mullvad.mullvadvpn.compose.state.LoginState.* +import net.mullvad.mullvadvpn.compose.state.LoginUiState import net.mullvad.mullvadvpn.model.AccountCreationResult import net.mullvad.mullvadvpn.model.AccountHistory +import net.mullvad.mullvadvpn.model.AccountToken import net.mullvad.mullvadvpn.model.DeviceListEvent import net.mullvad.mullvadvpn.model.LoginResult import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test class LoginViewModelTest { - @MockK private lateinit var mockedAccountRepository: AccountRepository - @MockK private lateinit var mockedDeviceRepository: DeviceRepository - @MockK private lateinit var mockedServiceConnectionContainer: ServiceConnectionContainer - private lateinit var loginViewModel: LoginViewModel - - private val accountCreationTestEvents = MutableSharedFlow() private val accountHistoryTestEvents = MutableStateFlow(AccountHistory.Missing) - private val loginTestEvents = MutableSharedFlow() - - private val serviceConnectionState = - MutableStateFlow(ServiceConnectionState.Disconnected) @Before fun setup() { Dispatchers.setMain(UnconfinedTestDispatcher()) MockKAnnotations.init(this, relaxUnitFun = true) - every { mockedAccountRepository.accountCreationEvents } returns accountCreationTestEvents - every { mockedAccountRepository.accountHistoryEvents } returns accountHistoryTestEvents - every { mockedAccountRepository.loginEvents } returns loginTestEvents - - serviceConnectionState.value = - ServiceConnectionState.ConnectedReady(mockedServiceConnectionContainer) + every { mockedAccountRepository.accountHistory } returns accountHistoryTestEvents loginViewModel = LoginViewModel( @@ -64,106 +50,144 @@ class LoginViewModelTest { } @Test - fun testDefaultState() = runBlockingTest { - loginViewModel.uiState.test { - assertEquals(LoginViewModel.LoginUiState.Default, awaitItem()) + fun testDefaultState() = + runTest(UnconfinedTestDispatcher()) { + loginViewModel.uiState.test { assertEquals(LoginUiState.INITIAL, awaitItem()) } } - } @Test - fun testCreateAccount() = runBlockingTest { - loginViewModel.uiState.test { - skipDefaultItem() - loginViewModel.createAccount() - assertEquals(LoginViewModel.LoginUiState.CreatingAccount, awaitItem()) - accountCreationTestEvents.emit(AccountCreationResult.Success(DUMMY_ACCOUNT_TOKEN)) - - assertEquals(LoginViewModel.LoginUiState.AccountCreated, awaitItem()) + fun testCreateAccount() = + runTest(UnconfinedTestDispatcher()) { + turbineScope { + val uiStates = loginViewModel.uiState.testIn(backgroundScope) + val sideEffects = loginViewModel.sideEffect.testIn(backgroundScope) + + uiStates.skipDefaultItem() + + coEvery { mockedAccountRepository.createAccount() } returns + AccountCreationResult.Success(DUMMY_ACCOUNT_TOKEN) + loginViewModel.createAccount() + assertEquals(Loading.CreatingAccount, uiStates.awaitItem().loginState) + assertEquals(LoginSideEffect.NavigateToWelcome, sideEffects.awaitItem()) + } } - } @Test - fun testLoginWithValidAccount() = runBlockingTest { - loginViewModel.uiState.test { - skipDefaultItem() - loginViewModel.login(DUMMY_ACCOUNT_TOKEN) - assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - loginTestEvents.emit(Event.LoginEvent(LoginResult.Ok)) - assertEquals(LoginViewModel.LoginUiState.Success(isOutOfTime = false), awaitItem()) + fun testLoginWithValidAccount() = + runTest(UnconfinedTestDispatcher()) { + turbineScope { + coEvery { mockedAccountRepository.login(any()) } returns LoginResult.Ok + + val uiStates = loginViewModel.uiState.testIn(backgroundScope) + val sideEffects = loginViewModel.sideEffect.testIn(backgroundScope) + + uiStates.skipDefaultItem() + + loginViewModel.login(DUMMY_ACCOUNT_TOKEN) + assertEquals(Loading.LoggingIn, uiStates.awaitItem().loginState) + assertEquals(Success, uiStates.awaitItem().loginState) + assertEquals(LoginSideEffect.NavigateToConnect, sideEffects.awaitItem()) + } } - } @Test - fun testLoginWithInvalidAccount() = runBlockingTest { - loginViewModel.uiState.test { - skipDefaultItem() - loginViewModel.login(DUMMY_ACCOUNT_TOKEN) - assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - loginTestEvents.emit(Event.LoginEvent(LoginResult.InvalidAccount)) - assertEquals(LoginViewModel.LoginUiState.InvalidAccountError, awaitItem()) + fun testLoginWithInvalidAccount() = + runTest(UnconfinedTestDispatcher()) { + coEvery { mockedAccountRepository.login(any()) } returns LoginResult.InvalidAccount + + loginViewModel.uiState.test { + skipDefaultItem() + + loginViewModel.login(DUMMY_ACCOUNT_TOKEN) + assertEquals(Loading.LoggingIn, awaitItem().loginState) + + assertEquals( + Idle(loginError = LoginError.InvalidCredentials), + awaitItem().loginState + ) + } } - } @Test - fun testLoginWithTooManyDevicesError() = runBlockingTest { - coEvery { - mockedDeviceRepository.refreshAndAwaitDeviceListWithTimeout(any(), any(), any(), any()) - } returns DeviceListEvent.Available(DUMMY_ACCOUNT_TOKEN, listOf()) - - loginViewModel.uiState.test { - skipDefaultItem() - loginViewModel.login(DUMMY_ACCOUNT_TOKEN) - assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - loginTestEvents.emit(Event.LoginEvent(LoginResult.MaxDevicesReached)) - assertEquals( - LoginViewModel.LoginUiState.TooManyDevicesError(DUMMY_ACCOUNT_TOKEN), - awaitItem() - ) + fun testLoginWithTooManyDevicesError() = + runTest(UnconfinedTestDispatcher()) { + coEvery { + mockedDeviceRepository.refreshAndAwaitDeviceListWithTimeout( + any(), + any(), + any(), + any() + ) + } returns DeviceListEvent.Available(DUMMY_ACCOUNT_TOKEN, listOf()) + + turbineScope { + val uiStates = loginViewModel.uiState.testIn(backgroundScope) + val sideEffects = loginViewModel.sideEffect.testIn(backgroundScope) + + uiStates.skipDefaultItem() + + coEvery { mockedAccountRepository.login(any()) } returns + LoginResult.MaxDevicesReached + loginViewModel.login(DUMMY_ACCOUNT_TOKEN) + assertEquals(Loading.LoggingIn, uiStates.awaitItem().loginState) + assertEquals( + LoginSideEffect.TooManyDevices(AccountToken(DUMMY_ACCOUNT_TOKEN)), + sideEffects.awaitItem() + ) + } } - } @Test - fun testLoginWithRpcError() = runBlockingTest { - loginViewModel.uiState.test { - skipDefaultItem() - loginViewModel.login(DUMMY_ACCOUNT_TOKEN) - assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - loginTestEvents.emit(Event.LoginEvent(LoginResult.RpcError)) - assertEquals( - LoginViewModel.LoginUiState.OtherError(EXPECTED_RPC_ERROR_MESSAGE), - awaitItem() - ) + fun testLoginWithRpcError() = + runTest(UnconfinedTestDispatcher()) { + loginViewModel.uiState.test { + skipDefaultItem() + + coEvery { mockedAccountRepository.login(any()) } returns LoginResult.RpcError + loginViewModel.login(DUMMY_ACCOUNT_TOKEN) + assertEquals(Loading.LoggingIn, awaitItem().loginState) + assertEquals( + Idle(LoginError.Unknown(EXPECTED_RPC_ERROR_MESSAGE)), + awaitItem().loginState + ) + } } - } @Test - fun testLoginWithUnknownError() = runBlockingTest { - loginViewModel.uiState.test { - skipDefaultItem() - loginViewModel.login(DUMMY_ACCOUNT_TOKEN) - assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - loginTestEvents.emit(Event.LoginEvent(LoginResult.OtherError)) - assertEquals( - LoginViewModel.LoginUiState.OtherError(EXPECTED_OTHER_ERROR_MESSAGE), - awaitItem() - ) + fun testLoginWithUnknownError() = + runTest(UnconfinedTestDispatcher()) { + loginViewModel.uiState.test { + skipDefaultItem() + + coEvery { mockedAccountRepository.login(any()) } returns LoginResult.OtherError + loginViewModel.login(DUMMY_ACCOUNT_TOKEN) + assertEquals(Loading.LoggingIn, awaitItem().loginState) + assertEquals( + Idle(LoginError.Unknown(EXPECTED_OTHER_ERROR_MESSAGE)), + awaitItem().loginState + ) + } } - } @Test - fun testAccountHistory() = runBlockingTest { - loginViewModel.accountHistory.test { - skipDefaultItem() - accountHistoryTestEvents.emit(AccountHistory.Available(DUMMY_ACCOUNT_TOKEN)) - assertEquals(AccountHistory.Available(DUMMY_ACCOUNT_TOKEN), awaitItem()) + fun testAccountHistory() = + runTest(UnconfinedTestDispatcher()) { + loginViewModel.uiState.test { + skipDefaultItem() + accountHistoryTestEvents.emit(AccountHistory.Available(DUMMY_ACCOUNT_TOKEN)) + assertEquals( + LoginUiState.INITIAL.copy(lastUsedAccount = AccountToken(DUMMY_ACCOUNT_TOKEN)), + awaitItem() + ) + } } - } @Test - fun testClearingAccountHistory() = runBlockingTest { - loginViewModel.clearAccountHistory() - verify { mockedAccountRepository.clearAccountHistory() } - } + fun testClearingAccountHistory() = + runTest(UnconfinedTestDispatcher()) { + loginViewModel.clearAccountHistory() + verify { mockedAccountRepository.clearAccountHistory() } + } private suspend fun ReceiveTurbine.skipDefaultItem() where T : Any? { awaitItem() diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountToken.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountToken.kt new file mode 100644 index 000000000000..2aeca352d088 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountToken.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.model + +@JvmInline value class AccountToken(val value: String) diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt index 132f2f9dc397..f2f984cf2b53 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt @@ -22,31 +22,12 @@ import net.mullvad.mullvadvpn.lib.theme.typeface.TypeScale private val MullvadTypography = Typography( headlineLarge = TextStyle(fontSize = TypeScale.TextHuge, fontWeight = FontWeight.Bold), - headlineSmall = - TextStyle( - color = MullvadWhite, - fontSize = TypeScale.TextBig, - fontWeight = FontWeight.Bold - ), - bodySmall = TextStyle(color = MullvadWhite, fontSize = TypeScale.TextSmall), - titleSmall = - TextStyle( - color = MullvadWhite, - fontSize = TypeScale.TextMedium, - fontWeight = FontWeight.SemiBold - ), + headlineSmall = TextStyle(fontSize = TypeScale.TextBig, fontWeight = FontWeight.Bold), + bodySmall = TextStyle(fontSize = TypeScale.TextSmall), + titleSmall = TextStyle(fontSize = TypeScale.TextMedium, fontWeight = FontWeight.SemiBold), titleMedium = - TextStyle( - color = MullvadWhite, - fontSize = TypeScale.TextMediumPlus, - fontWeight = FontWeight.SemiBold - ), - labelMedium = - TextStyle( - color = MullvadWhite60, - fontSize = TypeScale.TextSmall, - fontWeight = FontWeight.SemiBold - ), + TextStyle(fontSize = TypeScale.TextMediumPlus, fontWeight = FontWeight.SemiBold), + labelMedium = TextStyle(fontSize = TypeScale.TextSmall, fontWeight = FontWeight.SemiBold), labelLarge = TextStyle( fontWeight = FontWeight.Normal, diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt index aeee6593da6f..23e9910735ac 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt @@ -29,13 +29,16 @@ data class Dimensions( val loadingSpinnerPadding: Dp = 12.dp, val loadingSpinnerSize: Dp = 24.dp, val loadingSpinnerSizeMedium: Dp = 28.dp, - val loadingSpinnerStrokeWidth: Dp = 3.dp, + val loadingSpinnerStrokeWidth: Dp = 6.dp, + val loginIconContainerSize: Dp = 60.dp, + val smallPadding: Dp = 8.dp, val mediumPadding: Dp = 16.dp, + val largePadding: Dp = 32.dp, val notificationBannerStartPadding: Dp = 16.dp, val notificationBannerEndPadding: Dp = 12.dp, val notificationEndIconPadding: Dp = 4.dp, val notificationStatusIconSize: Dp = 10.dp, - val progressIndicatorSize: Dp = 60.dp, + val progressIndicatorSize: Dp = 48.dp, val relayCircleSize: Dp = 16.dp, val relayRowPadding: Dp = 50.dp, val screenVerticalMargin: Dp = 22.dp, @@ -46,7 +49,6 @@ data class Dimensions( val selectLocationTitlePadding: Dp = 12.dp, val selectableCellTextMargin: Dp = 12.dp, val sideMargin: Dp = 22.dp, - val smallPadding: Dp = 8.dp, val titleIconSize: Dp = 24.dp, val topBarHeight: Dp = 64.dp, val verticalSpace: Dp = 20.dp,