diff options
| author | David Göransson <david.goransson90@gmail.com> | 2023-09-22 09:14:44 +0200 |
|---|---|---|
| committer | David Göransson <david.goransson90@gmail.com> | 2023-09-27 08:23:26 +0200 |
| commit | 08a74bc5857076e7dc2eb3963c49ec775cedb688 (patch) | |
| tree | 46f9c584818d908332f7830c0eff2e3c7e82f038 /android | |
| parent | 24acf70d47b15e3d4ece53b8f0cd8c3dffcf52fd (diff) | |
| download | mullvadvpn-08a74bc5857076e7dc2eb3963c49ec775cedb688.tar.xz mullvadvpn-08a74bc5857076e7dc2eb3963c49ec775cedb688.zip | |
Migrate login screen to compose
Fixes removed and fixes some of the compose colors, recreates login states
Diffstat (limited to 'android')
13 files changed, 854 insertions, 589 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt index 15e94dddb9..331e9d2248 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt @@ -118,6 +118,7 @@ fun ContentBlockersDisableModeCellSubtitle(modifier: Modifier) { Text( text = spanned.toAnnotatedString(boldFontWeight = FontWeight.ExtraBold), style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondary, modifier = modifier ) } 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 c84804d955..a2e5eec8df 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 @@ -110,53 +111,52 @@ 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) - ) - - InformationView( - content = uiState.deviceName.capitalizeFirstCharOfEachWord(), - whenMissing = MissingPolicy.SHOW_SPINNER - ) - - Text( - style = MaterialTheme.typography.labelMedium, - text = stringResource(id = R.string.account_number), + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column( + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.Start, modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - top = Dimens.smallPadding - ) - ) + 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) + ) - CopyableObfuscationView(content = uiState.accountNumber) + InformationView( + content = uiState.deviceName.capitalizeFirstCharOfEachWord(), + whenMissing = MissingPolicy.SHOW_SPINNER + ) - 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.account_number), + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + top = Dimens.smallPadding + ) + ) - InformationView( - content = uiState.accountExpiry?.toExpiryDateString() ?: "", - whenMissing = MissingPolicy.SHOW_SPINNER - ) + CopyableObfuscationView(content = uiState.accountNumber) - Spacer(modifier = Modifier.weight(1f)) + 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 + ) + Spacer(modifier = Modifier.weight(1f)) if (IS_PLAY_BUILD.not()) { ActionButton( text = stringResource(id = R.string.manage_account), @@ -175,37 +175,38 @@ fun AccountScreen( ) } - 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.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/LoginScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt new file mode 100644 index 0000000000..b1a25fb581 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt @@ -0,0 +1,360 @@ +package net.mullvad.mullvadvpn.compose.screen + +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.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +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, + onLoginClick: (String) -> Unit = {}, + onCreateAccountClick: () -> Unit = {}, + onDeleteHistoryClick: () -> Unit = {}, + onAccountNumberChange: (String) -> 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 + ) { + val scrollState = rememberScrollState() + Column( + modifier = + Modifier.padding(it) + .fillMaxSize() + .background(MaterialTheme.colorScheme.primary) + .verticalScroll(scrollState) + ) { + Spacer(modifier = Modifier.weight(1f)) + LoginIcon( + state.loginState, + modifier = + Modifier.align(Alignment.CenterHorizontally) + .padding(bottom = Dimens.largePadding) + ) + LoginContent(state, onAccountNumberChange, onLoginClick, onDeleteHistoryClick) + Spacer(modifier = Modifier.weight(3f)) + CreateAccountPanel(onCreateAccountClick, isEnabled = state.loginState is Idle) + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun LoginContent( + state: LoginUiState, + onAccountNumberChange: (String) -> Unit, + onLoginClick: (String) -> Unit, + onDeleteHistoryClick: () -> Unit +) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Dimens.sideMargin)) { + Text( + text = state.loginState.title(), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.fillMaxWidth().padding(bottom = Dimens.smallPadding) + ) + + var expanded by remember { mutableStateOf(false) } + + Text( + modifier = Modifier.padding(bottom = Dimens.smallPadding), + text = state.loginState.supportingText() ?: "", + style = MaterialTheme.typography.labelMedium, + color = + if (state.loginState.isError()) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onPrimary + }, + ) + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + TextField( + modifier = Modifier.menuAnchor().fillMaxWidth(), + value = state.accountNumberInput, + label = { + Text( + text = stringResource(id = R.string.login_description), + color = Color.Unspecified + ) + }, + keyboardActions = + KeyboardActions(onDone = { onLoginClick(state.accountNumberInput) }), + keyboardOptions = + KeyboardOptions( + imeAction = + if (state.loginButtonEnabled) ImeAction.Done else ImeAction.None, + keyboardType = KeyboardType.NumberPassword + ), + onValueChange = onAccountNumberChange, + 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 = MaterialTheme.colorScheme.background, + 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 = { + onAccountNumberChange(token.value) + expanded = false + onLoginClick(token.value) + } + ) { + onDeleteHistoryClick() + } + } + } + } + + Spacer(modifier = Modifier.size(Dimens.largePadding)) + ActionButton( + isEnabled = state.loginButtonEnabled, + onClick = { onLoginClick(state.accountNumberInput) }, + colors = + ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.surface + ), + text = stringResource(id = R.string.login_title), + modifier = Modifier.padding(bottom = Dimens.mediumPadding) + ) + } +} + +@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.mediumPadding, 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/SplitTunnelingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt index 5170ef0845..f5e512f8f6 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 @@ -14,11 +14,13 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text 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 @@ -110,49 +112,108 @@ fun SplitTunnelingScreen( ) }, ) { - LazyColumn( - modifier = Modifier.drawVerticalScrollbar(state = lazyListState).fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - state = lazyListState - ) { - item(key = CommonContentKey.DESCRIPTION, contentType = ContentType.DESCRIPTION) { - Box(modifier = Modifier.fillMaxWidth()) { - Text( - style = MaterialTheme.typography.labelMedium, - text = stringResource(id = R.string.split_tunneling_description), - modifier = - Modifier.padding( - start = Dimens.mediumPadding, - end = Dimens.mediumPadding, - bottom = Dimens.mediumPadding - ) - ) - } - } - when (uiState) { - SplitTunnelingUiState.Loading -> { - item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.onBackground, + Surface(color = MaterialTheme.colorScheme.background) { + LazyColumn( + modifier = Modifier.drawVerticalScrollbar(state = lazyListState).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + state = lazyListState + ) { + item(key = CommonContentKey.DESCRIPTION, contentType = ContentType.DESCRIPTION) { + Box(modifier = Modifier.fillMaxWidth()) { + Text( + style = MaterialTheme.typography.labelMedium, + text = stringResource(id = R.string.split_tunneling_description), modifier = - Modifier.size( - width = Dimens.progressIndicatorSize, - height = Dimens.progressIndicatorSize + Modifier.padding( + start = Dimens.mediumPadding, + end = Dimens.mediumPadding, + bottom = Dimens.mediumPadding ) ) } } - is SplitTunnelingUiState.ShowAppList -> { - if (uiState.excludedApps.isNotEmpty()) { + when (uiState) { + SplitTunnelingUiState.Loading -> { + item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(Dimens.progressIndicatorSize), + strokeCap = StrokeCap.Round + ) + } + } + is SplitTunnelingUiState.ShowAppList -> { + if (uiState.excludedApps.isNotEmpty()) { + itemWithDivider( + key = SplitTunnelingContentKey.EXCLUDED_APPLICATIONS, + contentType = ContentType.HEADER + ) { + BaseCell( + title = { + Text( + text = + stringResource(id = R.string.exclude_applications), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimary + ) + }, + bodyView = {}, + background = MaterialTheme.colorScheme.primary, + ) + } + itemsIndexed( + items = uiState.excludedApps, + key = { _, listItem -> listItem.packageName }, + contentType = { _, _ -> ContentType.ITEM } + ) { index, listItem -> + SplitTunnelingCell( + title = listItem.name, + packageName = listItem.packageName, + isSelected = true, + modifier = Modifier.animateItemPlacement().fillMaxWidth(), + onResolveIcon = onResolveIcon + ) { + // Move focus down unless the clicked item was the last in this + // section. + if (index < uiState.excludedApps.size - 1) { + focusManager.moveFocus(FocusDirection.Down) + } else { + focusManager.moveFocus(FocusDirection.Up) + } + + onIncludeAppClick(listItem.packageName) + } + } + item(key = CommonContentKey.SPACER, contentType = ContentType.SPACER) { + Spacer( + modifier = + Modifier.animateItemPlacement().height(Dimens.mediumPadding) + ) + } + } + itemWithDivider( - key = SplitTunnelingContentKey.EXCLUDED_APPLICATIONS, + key = SplitTunnelingContentKey.SHOW_SYSTEM_APPLICATIONS, + contentType = ContentType.OTHER_ITEM + ) { + HeaderSwitchComposeCell( + title = stringResource(id = R.string.show_system_apps), + isToggled = uiState.showSystemApps, + onCellClicked = { newValue -> onShowSystemAppsClick(newValue) }, + modifier = Modifier.animateItemPlacement() + ) + } + itemWithDivider( + key = SplitTunnelingContentKey.INCLUDED_APPLICATIONS, contentType = ContentType.HEADER ) { BaseCell( + modifier = Modifier.animateItemPlacement(), title = { Text( - text = stringResource(id = R.string.exclude_applications), - style = MaterialTheme.typography.titleMedium + text = stringResource(id = R.string.all_applications), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimary ) }, bodyView = {}, @@ -160,84 +221,27 @@ fun SplitTunnelingScreen( ) } itemsIndexed( - items = uiState.excludedApps, + items = uiState.includedApps, key = { _, listItem -> listItem.packageName }, contentType = { _, _ -> ContentType.ITEM } ) { index, listItem -> SplitTunnelingCell( title = listItem.name, packageName = listItem.packageName, - isSelected = true, + isSelected = false, modifier = Modifier.animateItemPlacement().fillMaxWidth(), onResolveIcon = onResolveIcon ) { // Move focus down unless the clicked item was the last in this // section. - if (index < uiState.excludedApps.size - 1) { + if (index < uiState.includedApps.size - 1) { focusManager.moveFocus(FocusDirection.Down) } else { focusManager.moveFocus(FocusDirection.Up) } - onIncludeAppClick(listItem.packageName) - } - } - item(key = CommonContentKey.SPACER, contentType = ContentType.SPACER) { - Spacer( - modifier = - Modifier.animateItemPlacement().height(Dimens.mediumPadding) - ) - } - } - - itemWithDivider( - key = SplitTunnelingContentKey.SHOW_SYSTEM_APPLICATIONS, - contentType = ContentType.OTHER_ITEM - ) { - HeaderSwitchComposeCell( - title = stringResource(id = R.string.show_system_apps), - isToggled = uiState.showSystemApps, - onCellClicked = { newValue -> onShowSystemAppsClick(newValue) }, - modifier = Modifier.animateItemPlacement() - ) - } - itemWithDivider( - key = SplitTunnelingContentKey.INCLUDED_APPLICATIONS, - contentType = ContentType.HEADER - ) { - BaseCell( - modifier = Modifier.animateItemPlacement(), - title = { - Text( - text = stringResource(id = R.string.all_applications), - style = MaterialTheme.typography.titleMedium - ) - }, - bodyView = {}, - background = MaterialTheme.colorScheme.primary, - ) - } - itemsIndexed( - items = uiState.includedApps, - key = { _, listItem -> listItem.packageName }, - contentType = { _, _ -> ContentType.ITEM } - ) { index, listItem -> - SplitTunnelingCell( - title = listItem.name, - packageName = listItem.packageName, - isSelected = false, - modifier = Modifier.animateItemPlacement().fillMaxWidth(), - onResolveIcon = onResolveIcon - ) { - // Move focus down unless the clicked item was the last in this - // section. - if (index < uiState.includedApps.size - 1) { - focusManager.moveFocus(FocusDirection.Down) - } else { - focusManager.moveFocus(FocusDirection.Up) + onExcludeAppClick(listItem.packageName) } - - onExcludeAppClick(listItem.packageName) } } } 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 0000000000..bcbc181b85 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/LoginUiState.kt @@ -0,0 +1,40 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.model.AccountToken + +const val MIN_ACCOUNT_LOGIN_LENGTH = 8 + +data class LoginUiState( + val accountNumberInput: String = "", + val lastUsedAccount: AccountToken? = null, + val loginState: LoginState = LoginState.Idle(null) +) { + val loginButtonEnabled = + accountNumberInput.length >= MIN_ACCOUNT_LOGIN_LENGTH && loginState is LoginState.Idle + + 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 0000000000..2e294e48e4 --- /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 ddcde56407..de72fa8d93 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<String?>(null) val cachedCreatedAccount = _cachedCreatedAccount.asStateFlow() - val accountCreationEvents: SharedFlow<AccountCreationResult> = + private val accountCreationEvents: SharedFlow<AccountCreationResult> = serviceConnectionManager.connectionState .flatMapReadyConnectionOrDefault(flowOf()) { state -> state.container.accountDataSource.accountCreationResult @@ -49,7 +53,7 @@ class AccountRepository( AccountExpiry.Missing ) - val accountHistoryEvents: StateFlow<AccountHistory> = + val accountHistory: StateFlow<AccountHistory> = serviceConnectionManager.connectionState .flatMapReadyConnectionOrDefault(flowOf()) { state -> state.container.accountDataSource.accountHistory @@ -61,20 +65,26 @@ class AccountRepository( AccountHistory.Missing ) - val loginEvents: SharedFlow<Event.LoginEvent> = + private val loginEvents: SharedFlow<Event.LoginEvent> = 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 7e298e3f73..175d34df82 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,74 @@ 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.LoginViewAction 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<HeaderBar?>(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<AccountLogin>(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<View>(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 - } else { - false - } + // TODO: Remove this when we have a better solution for login after clearing max devices + val accountTokenArgument = arguments?.getString(ACCOUNT_TOKEN_ARGUMENT_KEY) + if (accountTokenArgument != null) { + // Login and set initial TextField value + vm.onAccountNumberChange(accountTokenArgument) + vm.login(accountTokenArgument) } - 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<ComposeView>(R.id.compose_view).setContent { + AppTheme { + val loginUiState by vm.uiState.collectAsState() + LaunchedEffect(Unit) { + vm.viewActions.collect { + when (it) { + LoginViewAction.NavigateToWelcome, + LoginViewAction + .NavigateToConnect -> {} // TODO Fix when we redo navigation + is LoginViewAction.TooManyDevices -> { + navigateToDeviceListFragment(it.accountToken) + } + } + } + } + LoginScreen( + loginUiState, + vm::login, + vm::createAccount, + vm::clearAccountHistory, + vm::onAccountNumberChange, + ::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 +84,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 07e8f48705..194b6462a5 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,129 @@ 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 LoginViewAction { + data object NavigateToWelcome : LoginViewAction + + data object NavigateToConnect : LoginViewAction + + data class TooManyDevices(val accountToken: AccountToken) : LoginViewAction +} + class LoginViewModel( private val accountRepository: AccountRepository, private val deviceRepository: DeviceRepository, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { - private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Default) - val uiState: StateFlow<LoginUiState> = _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() - - data object InvalidAccountError : LoginUiState() - - data class TooManyDevicesError(val accountToken: String) : LoginUiState() + private val _loginState = MutableStateFlow(LoginUiState.INITIAL.loginState) + private val _loginInput = MutableStateFlow(LoginUiState.INITIAL.accountNumberInput) - data object TooManyDevicesMissingListError : LoginUiState() + private val _viewActions = MutableSharedFlow<LoginViewAction>(extraBufferCapacity = 1) + val viewActions = _viewActions.asSharedFlow() - data class OtherError(val errorMessage: String) : LoginUiState() - - fun isLoading(): Boolean { - return this is Loading + private val _uiState = + combine( + _loginInput, + accountRepository.accountHistory, + _loginState, + ) { loginInput, accountHistoryState, loginState -> + LoginUiState( + loginInput, + accountHistoryState.accountToken()?.let(::AccountToken), + loginState + ) } - } + val uiState: StateFlow<LoginUiState> = + _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) - private fun AccountCreationResult.mapToUiState(): LoginUiState { - return if (this is AccountCreationResult.Success) { - LoginUiState.AccountCreated - } else { - LoginUiState.UnableToCreateAccountError + val uiState = + when (val result = loginDeferred.await()) { + LoginResult.Ok -> { + launch { + delay(1000) + _viewActions.emit(LoginViewAction.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 + _viewActions.emit( + LoginViewAction.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 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 - ) + fun onAccountNumberChange(accountNumber: String) { + _loginInput.value = accountNumber.filter { it.isDigit() } + // If there is an error, clear it + _loginState.update { if (it is Idle) Idle() else it } + } - if (refreshResult.isAvailable()) { - LoginUiState.TooManyDevicesError(accountToken) - } else { - LoginUiState.TooManyDevicesMissingListError - } - } - else -> LoginUiState.OtherError(errorMessage = this.toString()) + private suspend fun AccountCreationResult.mapToUiState(): LoginState? { + return if (this is AccountCreationResult.Success) { + _viewActions.emit(LoginViewAction.NavigateToWelcome) + null + } else { + 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 4ba80511c6..73bfd1ef38 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,48 @@ 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.lib.common.test.TestCoroutineRule 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.Rule import org.junit.Test class LoginViewModelTest { + @get:Rule val testCoroutineRule = TestCoroutineRule() @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<AccountCreationResult>() private val accountHistoryTestEvents = MutableStateFlow<AccountHistory>(AccountHistory.Missing) - private val loginTestEvents = MutableSharedFlow<Event.LoginEvent>() - - private val serviceConnectionState = - MutableStateFlow<ServiceConnectionState>(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,103 +54,135 @@ class LoginViewModelTest { } @Test - fun testDefaultState() = runBlockingTest { - loginViewModel.uiState.test { - assertEquals(LoginViewModel.LoginUiState.Default, awaitItem()) - } + fun testDefaultState() = runTest { + 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)) + fun testCreateAccount() = runTest { + turbineScope { + // Arrange + val uiStates = loginViewModel.uiState.testIn(backgroundScope) + val sideEffects = loginViewModel.viewActions.testIn(backgroundScope) + coEvery { mockedAccountRepository.createAccount() } returns + AccountCreationResult.Success(DUMMY_ACCOUNT_TOKEN) - assertEquals(LoginViewModel.LoginUiState.AccountCreated, awaitItem()) + // Act, Assert + uiStates.skipDefaultItem() + loginViewModel.createAccount() + assertEquals(Loading.CreatingAccount, uiStates.awaitItem().loginState) + assertEquals(LoginViewAction.NavigateToWelcome, sideEffects.awaitItem()) } } @Test - fun testLoginWithValidAccount() = runBlockingTest { - loginViewModel.uiState.test { - skipDefaultItem() + fun testLoginWithValidAccount() = runTest { + turbineScope { + // Arrange + val uiStates = loginViewModel.uiState.testIn(backgroundScope) + val sideEffects = loginViewModel.viewActions.testIn(backgroundScope) + coEvery { mockedAccountRepository.login(any()) } returns LoginResult.Ok + + // Act, Assert + uiStates.skipDefaultItem() loginViewModel.login(DUMMY_ACCOUNT_TOKEN) - assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - loginTestEvents.emit(Event.LoginEvent(LoginResult.Ok)) - assertEquals(LoginViewModel.LoginUiState.Success(isOutOfTime = false), awaitItem()) + assertEquals(Loading.LoggingIn, uiStates.awaitItem().loginState) + assertEquals(Success, uiStates.awaitItem().loginState) + assertEquals(LoginViewAction.NavigateToConnect, sideEffects.awaitItem()) } } @Test - fun testLoginWithInvalidAccount() = runBlockingTest { + fun testLoginWithInvalidAccount() = runTest { loginViewModel.uiState.test { + // Arrange + coEvery { mockedAccountRepository.login(any()) } returns LoginResult.InvalidAccount + + // Act, Assert skipDefaultItem() loginViewModel.login(DUMMY_ACCOUNT_TOKEN) - assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - loginTestEvents.emit(Event.LoginEvent(LoginResult.InvalidAccount)) - assertEquals(LoginViewModel.LoginUiState.InvalidAccountError, awaitItem()) + 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()) + fun testLoginWithTooManyDevicesError() = runTest { + turbineScope { + // Arrange + val uiStates = loginViewModel.uiState.testIn(backgroundScope) + val sideEffects = loginViewModel.viewActions.testIn(backgroundScope) + coEvery { + mockedDeviceRepository.refreshAndAwaitDeviceListWithTimeout( + any(), + any(), + any(), + any() + ) + } returns DeviceListEvent.Available(DUMMY_ACCOUNT_TOKEN, listOf()) + coEvery { mockedAccountRepository.login(any()) } returns LoginResult.MaxDevicesReached - loginViewModel.uiState.test { - skipDefaultItem() + // Act, Assert + uiStates.skipDefaultItem() loginViewModel.login(DUMMY_ACCOUNT_TOKEN) - assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - loginTestEvents.emit(Event.LoginEvent(LoginResult.MaxDevicesReached)) + assertEquals(Loading.LoggingIn, uiStates.awaitItem().loginState) assertEquals( - LoginViewModel.LoginUiState.TooManyDevicesError(DUMMY_ACCOUNT_TOKEN), - awaitItem() + LoginViewAction.TooManyDevices(AccountToken(DUMMY_ACCOUNT_TOKEN)), + sideEffects.awaitItem() ) } } @Test - fun testLoginWithRpcError() = runBlockingTest { + fun testLoginWithRpcError() = runTest { loginViewModel.uiState.test { + // Arrange + coEvery { mockedAccountRepository.login(any()) } returns LoginResult.RpcError + + // Act, Assert skipDefaultItem() loginViewModel.login(DUMMY_ACCOUNT_TOKEN) - assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - loginTestEvents.emit(Event.LoginEvent(LoginResult.RpcError)) + assertEquals(Loading.LoggingIn, awaitItem().loginState) assertEquals( - LoginViewModel.LoginUiState.OtherError(EXPECTED_RPC_ERROR_MESSAGE), - awaitItem() + Idle(LoginError.Unknown(EXPECTED_RPC_ERROR_MESSAGE)), + awaitItem().loginState ) } } @Test - fun testLoginWithUnknownError() = runBlockingTest { + fun testLoginWithUnknownError() = runTest { loginViewModel.uiState.test { + // Arrange + coEvery { mockedAccountRepository.login(any()) } returns LoginResult.OtherError + + // Act, Assert skipDefaultItem() loginViewModel.login(DUMMY_ACCOUNT_TOKEN) - assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - loginTestEvents.emit(Event.LoginEvent(LoginResult.OtherError)) + assertEquals(Loading.LoggingIn, awaitItem().loginState) assertEquals( - LoginViewModel.LoginUiState.OtherError(EXPECTED_OTHER_ERROR_MESSAGE), - awaitItem() + Idle(LoginError.Unknown(EXPECTED_OTHER_ERROR_MESSAGE)), + awaitItem().loginState ) } } @Test - fun testAccountHistory() = runBlockingTest { - loginViewModel.accountHistory.test { + fun testAccountHistory() = runTest { + loginViewModel.uiState.test { + // Act, Assert skipDefaultItem() accountHistoryTestEvents.emit(AccountHistory.Available(DUMMY_ACCOUNT_TOKEN)) - assertEquals(AccountHistory.Available(DUMMY_ACCOUNT_TOKEN), awaitItem()) + assertEquals( + LoginUiState.INITIAL.copy(lastUsedAccount = AccountToken(DUMMY_ACCOUNT_TOKEN)), + awaitItem() + ) } } @Test - fun testClearingAccountHistory() = runBlockingTest { + fun testClearingAccountHistory() = runTest { + // Act, Assert loginViewModel.clearAccountHistory() verify { mockedAccountRepository.clearAccountHistory() } } 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 0000000000..2aeca352d0 --- /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 7e48417168..e9490d31d4 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,32 +22,13 @@ 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 - ), + headlineSmall = TextStyle(fontSize = TypeScale.TextBig, fontWeight = FontWeight.Bold), + bodySmall = TextStyle(fontSize = TypeScale.TextSmall), + titleSmall = TextStyle(fontSize = TypeScale.TextMedium, fontWeight = FontWeight.SemiBold), bodyMedium = TextStyle(fontSize = TypeScale.TextMediumPlus, fontWeight = FontWeight.Bold), - bodySmall = TextStyle(color = MullvadWhite, fontSize = TypeScale.TextSmall), - titleSmall = - TextStyle( - color = MullvadWhite, - 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 aeee6593da..23e9910735 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, |
