diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-09-27 09:06:50 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-09-27 09:06:50 +0200 |
| commit | 0377ffd3fb9e0710559b6bddc8de2f180e890504 (patch) | |
| tree | f96e38bcdf11d73a31a78908f72ad2780cf4503c | |
| parent | 24acf70d47b15e3d4ece53b8f0cd8c3dffcf52fd (diff) | |
| parent | 1ede00280c0042c6402778d5bb7c1f01ba5823b2 (diff) | |
| download | mullvadvpn-0377ffd3fb9e0710559b6bddc8de2f180e890504.tar.xz mullvadvpn-0377ffd3fb9e0710559b6bddc8de2f180e890504.zip | |
Merge branch 'migrate-the-login-view-to-compose-droid-53'
25 files changed, 884 insertions, 1405 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index c76f35844a..d59f2bfdc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Line wrap the file at 100 chars. Th - Migrate in app notifications to compose. - Move out of time evaluation to connect view model. - Migrate out of time view to compose. +- Migrate login view to compose. #### Linux - Don't block forwarding of traffic when the split tunnel mark (ct mark) is set. 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..22dfc34269 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,57 +111,73 @@ 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 (IS_PLAY_BUILD.not()) { + 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 (IS_PLAY_BUILD.not()) { 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, @@ -173,39 +190,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/LoginScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt new file mode 100644 index 0000000000..3c4d9e1202 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt @@ -0,0 +1,383 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.animation.AnimatedVisibility +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.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CornerSize +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.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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.onFocusChanged +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.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +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(uiState = LoginUiState()) } +} + +@Preview +@Composable +private fun PreviewLoggingIn() { + AppTheme { LoginScreen(uiState = LoginUiState(loginState = Loading.LoggingIn)) } +} + +@Preview +@Composable +private fun PreviewCreatingAccount() { + AppTheme { LoginScreen(uiState = LoginUiState(loginState = Loading.CreatingAccount)) } +} + +@Preview +@Composable +private fun PreviewLoginError() { + AppTheme { + LoginScreen(uiState = LoginUiState(loginState = Idle(LoginError.InvalidCredentials))) + } +} + +@Preview +@Composable +private fun PreviewLoginSuccess() { + AppTheme { LoginScreen(uiState = LoginUiState(loginState = Success)) } +} + +@Composable +fun LoginScreen( + uiState: 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( + uiState.loginState, + modifier = + Modifier.align(Alignment.CenterHorizontally) + .padding(bottom = Dimens.largePadding) + ) + LoginContent(uiState, onAccountNumberChange, onLoginClick, onDeleteHistoryClick) + Spacer(modifier = Modifier.weight(3f)) + CreateAccountPanel(onCreateAccountClick, isEnabled = uiState.loginState is Idle) + } + } +} + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +private fun LoginContent( + uiState: LoginUiState, + onAccountNumberChange: (String) -> Unit, + onLoginClick: (String) -> Unit, + onDeleteHistoryClick: () -> Unit +) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Dimens.sideMargin)) { + Text( + text = uiState.loginState.title(), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.fillMaxWidth().padding(bottom = Dimens.smallPadding) + ) + + var tfFocusState: FocusState? by remember { mutableStateOf(null) } + var ddFocusState: FocusState? by remember { mutableStateOf(null) } + val expandedDropdown = tfFocusState?.hasFocus ?: false || ddFocusState?.hasFocus ?: false + + Text( + modifier = Modifier.padding(bottom = Dimens.smallPadding), + text = uiState.loginState.supportingText() ?: "", + style = MaterialTheme.typography.labelMedium, + color = + if (uiState.loginState.isError()) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onPrimary + }, + ) + + TextField( + modifier = + // Fix for DPad navigation + Modifier.onFocusChanged { tfFocusState = it } + .focusProperties { + left = FocusRequester.Cancel + right = FocusRequester.Cancel + } + .fillMaxWidth(), + value = uiState.accountNumberInput, + label = { + Text( + text = stringResource(id = R.string.login_description), + color = Color.Unspecified + ) + }, + keyboardActions = + KeyboardActions(onDone = { onLoginClick(uiState.accountNumberInput) }), + keyboardOptions = + KeyboardOptions( + imeAction = if (uiState.loginButtonEnabled) ImeAction.Done else ImeAction.None, + keyboardType = KeyboardType.NumberPassword + ), + onValueChange = onAccountNumberChange, + singleLine = true, + maxLines = 1, + visualTransformation = accountTokenVisualTransformation(), + enabled = uiState.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 = uiState.loginState.isError(), + ) + + AnimatedVisibility(visible = uiState.lastUsedAccount != null && expandedDropdown) { + val token = uiState.lastUsedAccount?.value.orEmpty() + val accountTransformation = remember { accountTokenVisualTransformation() } + val transformedText = + remember(token) { accountTransformation.filter(AnnotatedString(token)).text } + + AccountDropDownItem( + modifier = Modifier.onFocusChanged { ddFocusState = it }, + accountToken = transformedText.toString(), + onClick = { + uiState.lastUsedAccount?.let { + onAccountNumberChange(it.value) + onLoginClick(it.value) + } + }, + onDeleteClick = onDeleteHistoryClick + ) + } + + Spacer(modifier = Modifier.size(Dimens.largePadding)) + ActionButton( + isEnabled = uiState.loginButtonEnabled, + onClick = { onLoginClick(uiState.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(loginState: LoginState, modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(Dimens.loginIconContainerSize) + ) { + when (loginState) { + is Idle -> + if (loginState.loginError != null) { + Image( + painter = painterResource(id = R.drawable.icon_fail), + contentDescription = stringResource(id = R.string.login_fail_title), + contentScale = ContentScale.Inside + ) + } else { + // If view is Idle, we display empty box to keep the same size as other states + } + 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 = stringResource(id = R.string.logged_in_title), + ) + } + } +} + +@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( + modifier: Modifier = Modifier, + accountToken: String, + onClick: () -> Unit, + onDeleteClick: () -> Unit +) { + Row( + modifier = + modifier + .clip( + MaterialTheme.shapes.medium.copy( + topStart = CornerSize(0f), + topEnd = CornerSize(0f) + ) + ) + .background(MaterialTheme.colorScheme.background) + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = + Modifier.clickable(onClick = onClick) + .fillMaxHeight() + .weight(1f) + .padding(horizontal = Dimens.mediumPadding, vertical = Dimens.smallPadding), + contentAlignment = Alignment.CenterStart + ) { + Text(text = accountToken, overflow = TextOverflow.Clip) + } + 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..3160ff9ac1 --- /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 - 1) / (ACCOUNT_TOKEN_CHUNK_SIZE + 1) + } + ) +} 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..466b0eca85 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 uiState 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( + uiState, + 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/ui/widget/AccountHistoryAdapter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountHistoryAdapter.kt deleted file mode 100644 index e60d9c406f..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountHistoryAdapter.kt +++ /dev/null @@ -1,38 +0,0 @@ -package net.mullvad.mullvadvpn.ui.widget - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView.Adapter -import kotlin.properties.Delegates.observable -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.util.SegmentedTextFormatter - -class AccountHistoryAdapter : Adapter<AccountHistoryHolder>() { - private val formatter = - SegmentedTextFormatter(' ').apply { - isValidInputCharacter = { character -> '0' <= character && character <= '9' } - } - - var accountHistory by observable<String?>(null) { _, _, _ -> notifyDataSetChanged() } - - var onSelectEntry: ((String) -> Unit)? = null - var onRemoveEntry: (() -> Unit)? = null - var onChildFocusChanged: ((String, Boolean) -> Unit)? = null - - override fun onCreateViewHolder(parentView: ViewGroup, type: Int): AccountHistoryHolder { - val inflater = LayoutInflater.from(parentView.context) - val view = inflater.inflate(R.layout.account_history_entry, parentView, false) - - return AccountHistoryHolder(view, formatter).apply { - onSelect = { account -> onSelectEntry?.invoke(account) } - onRemove = { _ -> onRemoveEntry?.invoke() } - onFocusChanged = { account, hasFocus -> onChildFocusChanged?.invoke(account, hasFocus) } - } - } - - override fun onBindViewHolder(holder: AccountHistoryHolder, position: Int) { - holder.accountToken = accountHistory ?: "" - } - - override fun getItemCount() = if (accountHistory !== null) 1 else 0 -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountHistoryHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountHistoryHolder.kt deleted file mode 100644 index 20685a0ca3..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountHistoryHolder.kt +++ /dev/null @@ -1,37 +0,0 @@ -package net.mullvad.mullvadvpn.ui.widget - -import android.view.View -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView.ViewHolder -import kotlin.properties.Delegates.observable -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.util.SegmentedTextFormatter - -class AccountHistoryHolder(view: View, private val formatter: SegmentedTextFormatter) : - ViewHolder(view) { - private val label: TextView = view.findViewById(R.id.label) - - var accountToken by observable("") { _, _, account -> label.text = formatter.format(account) } - - var onSelect: ((String) -> Unit)? = null - var onRemove: ((String) -> Unit)? = null - var onFocusChanged: ((String, Boolean) -> Unit)? = null - - init { - view.findViewById<View>(R.id.remove).apply { - setOnClickListener { onRemove?.invoke(accountToken) } - - setOnFocusChangeListener { _, hasFocus -> - onFocusChanged?.invoke(accountToken, hasFocus) - } - } - - label.apply { - setOnClickListener { onSelect?.invoke(accountToken) } - - setOnFocusChangeListener { _, hasFocus -> - onFocusChanged?.invoke(accountToken, hasFocus) - } - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountInput.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountInput.kt deleted file mode 100644 index 11759d469e..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountInput.kt +++ /dev/null @@ -1,184 +0,0 @@ -package net.mullvad.mullvadvpn.ui.widget - -import android.content.Context -import android.text.Editable -import android.text.TextWatcher -import android.text.method.DigitsKeyListener -import android.text.style.MetricAffectingSpan -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.view.View.OnFocusChangeListener -import android.widget.EditText -import android.widget.ImageButton -import android.widget.LinearLayout -import kotlin.properties.Delegates.observable -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.ui.LoginState -import net.mullvad.mullvadvpn.util.SegmentedInputFormatter -import net.mullvad.mullvadvpn.util.setOnEnterOrDoneAction -import net.mullvad.talpid.util.EventNotifier - -const val MIN_ACCOUNT_TOKEN_LENGTH = 10 - -class AccountInput : LinearLayout { - private val disabledTextColor = context.getColor(R.color.white) - private val enabledTextColor = context.getColor(R.color.blue) - private val errorTextColor = context.getColor(R.color.red) - - private val container = - context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> - val inflater = service as LayoutInflater - - inflater.inflate(R.layout.account_input, this) - } - - private val inputWatcher = - object : TextWatcher { - override fun beforeTextChanged( - text: CharSequence, - start: Int, - count: Int, - after: Int - ) {} - - override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {} - - override fun afterTextChanged(text: Editable) { - removeFormattingSpans(text) - setButtonEnabled(text.length >= MIN_ACCOUNT_TOKEN_LENGTH) - onTextChanged.notify(text.toString()) - } - } - - val input = - container.findViewById<EditText>(R.id.login_input).apply { - addTextChangedListener(inputWatcher) - setOnEnterOrDoneAction(::login) - - onFocusChangeListener = OnFocusChangeListener { view, inputHasFocus -> - hasFocus = inputHasFocus && view.isEnabled - } - - // Manually initializing the `DigitsKeyListener` allows spaces to be used and still - // keeps - // the input type as a number so that the correct software keyboard type is shown - keyListener = DigitsKeyListener.getInstance("01234567890 ") - - SegmentedInputFormatter(this, ' ').apply { - isValidInputCharacter = { character -> '0' <= character && character <= '9' } - } - } - - private val button = - container.findViewById<ImageButton>(R.id.login_button).apply { - setOnClickListener { login() } - } - - val onFocusChanged = EventNotifier(false) - private var hasFocus by onFocusChanged.notifiable() - - val onTextChanged = EventNotifier("") - - var loginState by - observable(LoginState.Initial) { _, _, state -> - when (state) { - LoginState.Initial -> initialState() - LoginState.InProgress -> loggingInState() - LoginState.Success -> successState() - LoginState.Failure -> failureState() - } - } - - var onLogin: ((String) -> Unit)? = null - - constructor(context: Context) : super(context) - - constructor(context: Context, attributes: AttributeSet) : super(context, attributes) - - constructor( - context: Context, - attributes: AttributeSet, - defaultStyleAttribute: Int - ) : super(context, attributes, defaultStyleAttribute) - - init { - orientation = HORIZONTAL - - setButtonEnabled(false) - } - - fun loginWith(accountNumber: String) { - input.setText(accountNumber) - onLogin?.invoke(accountNumber) - } - - private fun login() { - onLogin?.invoke(input.text.replace(Regex("[^0-9]"), "")) - } - - private fun initialState() { - input.apply { - setTextColor(enabledTextColor) - isEnabled = true - isFocusableInTouchMode = true - visibility = View.VISIBLE - } - - button.visibility = View.VISIBLE - setButtonEnabled(input.text.length >= MIN_ACCOUNT_TOKEN_LENGTH) - } - - private fun loggingInState() { - input.apply { - setTextColor(disabledTextColor) - isEnabled = false - isFocusable = false - visibility = View.VISIBLE - } - - button.visibility = View.GONE - setButtonEnabled(false) - } - - private fun successState() { - input.apply { - setTextColor(disabledTextColor) - isEnabled = false - isFocusable = false - visibility = View.VISIBLE - } - - button.visibility = View.GONE - setButtonEnabled(false) - } - - private fun failureState() { - button.visibility = View.VISIBLE - setButtonEnabled(true) - - input.apply { - setTextColor(errorTextColor) - isEnabled = true - isFocusableInTouchMode = true - visibility = View.VISIBLE - requestFocus() - } - } - - private fun setButtonEnabled(enabled: Boolean) { - button.apply { - if (enabled != isEnabled) { - isEnabled = enabled - isClickable = enabled - isFocusable = enabled - } - } - } - - private fun removeFormattingSpans(text: Editable) { - for (span in text.getSpans(0, text.length, MetricAffectingSpan::class.java)) { - text.removeSpan(span) - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLogin.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLogin.kt deleted file mode 100644 index 82dc8b6451..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLogin.kt +++ /dev/null @@ -1,221 +0,0 @@ -package net.mullvad.mullvadvpn.ui.widget - -import android.animation.ValueAnimator -import android.app.Activity -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View.OnLayoutChangeListener -import android.view.inputmethod.InputMethodManager -import android.widget.RelativeLayout -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import kotlin.properties.Delegates.observable -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.ui.ListItemDividerDecoration -import net.mullvad.mullvadvpn.ui.LoginState -import net.mullvad.mullvadvpn.ui.widget.AccountLoginBorder.BorderState -import net.mullvad.mullvadvpn.util.Debouncer - -class AccountLogin : RelativeLayout { - companion object { - private val MAX_ACCOUNT_HISTORY_ENTRIES = 3 - } - - fun setAccountToken(accountToken: String) { - input.input.setText(accountToken) - } - - private val focusDebouncer = - Debouncer(false).apply { listener = { hasFocus -> focused = hasFocus } } - - private val container = - context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> - val inflater = service as LayoutInflater - - inflater.inflate(R.layout.account_login, this) - } - - private val border: AccountLoginBorder = container.findViewById(R.id.border) - private val accountHistoryList: RecyclerView = container.findViewById(R.id.history) - private val input: AccountInput = container.findViewById(R.id.input) - - private val historyAdapter = - AccountHistoryAdapter().apply { - onSelectEntry = { account -> input.loginWith(account) } - onChildFocusChanged = { _, hasFocus -> focusDebouncer.rawValue = hasFocus } - } - - private val dividerHeight = resources.getDimensionPixelSize(R.dimen.account_history_divider) - private val historyEntryHeight = - resources.getDimensionPixelSize(R.dimen.account_history_entry_height) - - private val historyAnimation = - ValueAnimator.ofInt(0, 0).apply { - addUpdateListener { animation -> updateHeight(animation.animatedValue as Int) } - - duration = 350 - } - - private val maxHeight: Int - get() = MAX_ACCOUNT_HISTORY_ENTRIES * (historyEntryHeight + dividerHeight) - - private val expandedHeight: Int - get() = collapsedHeight + (historyHeight ?: 0) - - private var historyHeight by - observable<Int?>(null) { _, oldHistoryHeight, newHistoryHeight -> - if (newHistoryHeight != oldHistoryHeight) { - historyAnimation.setIntValues(collapsedHeight, expandedHeight) - reposition() - } - } - - private var collapsedHeight by - observable(resources.getDimensionPixelSize(R.dimen.account_login_input_height)) { - _, - oldCollapsedHeight, - newCollapsedHeight -> - if (newCollapsedHeight != oldCollapsedHeight) { - historyAnimation.setIntValues(newCollapsedHeight, expandedHeight) - reposition() - } - } - - private var focused by - observable(false) { _, _, hasFocus -> - updateBorder() - shouldShowAccountHistory = hasFocus - - if (!hasFocus) { - hideKeyboard() - } - } - - private var shouldShowAccountHistory by - observable(false) { _, isShown, show -> - if (isShown != show) { - if (show) { - historyAnimation.start() - } else { - historyAnimation.reverse() - } - } - } - - val hasFocus - get() = focused - - var accountHistory by - observable<String?>(null) { _, _, history -> - if (history != null) { - historyHeight = historyEntryHeight + dividerHeight - historyAdapter.accountHistory = history - } else { - historyHeight = 0 - } - } - - var state: LoginState by - observable(LoginState.Initial) { _, _, newState -> - input.loginState = newState - - updateBorder() - } - - var onLogin: ((String) -> Unit)? - get() = input.onLogin - set(value) { - input.onLogin = value - } - - var onClearHistory: (() -> Unit)? - get() = historyAdapter.onRemoveEntry - set(value) { - historyAdapter.onRemoveEntry = value - } - - constructor(context: Context) : super(context) - - constructor(context: Context, attributes: AttributeSet) : super(context, attributes) - - constructor( - context: Context, - attributes: AttributeSet, - defaultStyleAttribute: Int - ) : super(context, attributes, defaultStyleAttribute) - - init { - border.elevation = elevation + 0.1f - - input.apply { - onFocusChanged.subscribe(this) { hasFocus -> focusDebouncer.rawValue = hasFocus } - - onTextChanged.subscribe(this) { _ -> - if (state == LoginState.Failure) { - state = LoginState.Initial - } - } - - addOnLayoutChangeListener( - OnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ -> - collapsedHeight = bottom - top - } - ) - } - - accountHistoryList.apply { - layoutManager = LinearLayoutManager(context) - adapter = historyAdapter - - addItemDecoration( - ListItemDividerDecoration( - topOffset = resources.getDimensionPixelSize(R.dimen.account_history_divider) - ) - ) - } - - historyAnimation.setIntValues(collapsedHeight, expandedHeight) - } - - fun onDestroy() { - input.onFocusChanged.unsubscribe(this) - input.onTextChanged.unsubscribe(this) - } - - private fun updateBorder() { - if (state == LoginState.Failure) { - border.borderState = BorderState.ERROR - } else if (focused) { - border.borderState = BorderState.FOCUSED - } else { - border.borderState = BorderState.UNFOCUSED - } - } - - private fun updateHeight(height: Int) { - val layoutParams = container.layoutParams as MarginLayoutParams - - layoutParams.height = height - layoutParams.bottomMargin = maxHeight - height - - container.layoutParams = layoutParams - } - - private fun reposition() { - historyAnimation.cancel() - - if (shouldShowAccountHistory) { - updateHeight(expandedHeight) - } else { - updateHeight(collapsedHeight) - } - } - - private fun hideKeyboard() { - val inputManagerId = Activity.INPUT_METHOD_SERVICE - val inputManager = context.getSystemService(inputManagerId) as InputMethodManager - - inputManager.hideSoftInputFromWindow(windowToken, 0) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLoginBorder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLoginBorder.kt deleted file mode 100644 index 553ce7a4cf..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLoginBorder.kt +++ /dev/null @@ -1,108 +0,0 @@ -package net.mullvad.mullvadvpn.ui.widget - -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.ImageView -import android.widget.RelativeLayout -import androidx.core.content.res.ResourcesCompat -import net.mullvad.mullvadvpn.R - -class AccountLoginBorder : RelativeLayout { - enum class BorderState { - UNFOCUSED, - FOCUSED, - ERROR - } - - // The horizontal and vertical drawables are identical, but they must be separate objects - // because the view that uses them changes the bounds of the drawable. If they are shared - // between the horizontal and vertical views either the drawable becomes a vertical line or a - // horizontal line, and as a consequence either the horizontal or the vertical borders don't - // show correctly, respectively. - private class StateDrawables( - val corner: Drawable?, - val horizontalBorder: Drawable?, - val verticalBorder: Drawable? - ) - - private val unfocusedDrawables = - StateDrawables( - ResourcesCompat.getDrawable(resources, R.drawable.account_login_corner, null), - ResourcesCompat.getDrawable(resources, R.drawable.account_login_border, null), - ResourcesCompat.getDrawable(resources, R.drawable.account_login_border, null) - ) - - private val focusedDrawables = - StateDrawables( - ResourcesCompat.getDrawable(resources, R.drawable.account_login_corner_focused, null), - ResourcesCompat.getDrawable(resources, R.drawable.account_login_border_focused, null), - ResourcesCompat.getDrawable(resources, R.drawable.account_login_border_focused, null) - ) - - private val errorDrawables = - StateDrawables( - ResourcesCompat.getDrawable(resources, R.drawable.account_login_corner_error, null), - ResourcesCompat.getDrawable(resources, R.drawable.account_login_border_error, null), - ResourcesCompat.getDrawable(resources, R.drawable.account_login_border_error, null) - ) - - private val container = - context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> - val inflater = service as LayoutInflater - - inflater.inflate(R.layout.account_login_border, this) - } - - private val topLeftCorner: ImageView = container.findViewById(R.id.top_left_corner) - private val topRightCorner: ImageView = container.findViewById(R.id.top_right_corner) - private val bottomLeftCorner: ImageView = container.findViewById(R.id.bottom_left_corner) - private val bottomRightCorner: ImageView = container.findViewById(R.id.bottom_right_corner) - - private val topBorder: ImageView = container.findViewById(R.id.top_border) - private val leftBorder: ImageView = container.findViewById(R.id.left_border) - private val rightBorder: ImageView = container.findViewById(R.id.right_border) - private val bottomBorder: ImageView = container.findViewById(R.id.bottom_border) - - var borderState = BorderState.UNFOCUSED - set(value) { - field = value - - when (value) { - BorderState.UNFOCUSED -> setBorder(unfocusedDrawables) - BorderState.FOCUSED -> setBorder(focusedDrawables) - BorderState.ERROR -> setBorder(errorDrawables) - } - } - - constructor(context: Context) : super(context) - - constructor(context: Context, attributes: AttributeSet) : super(context, attributes) - - constructor( - context: Context, - attributes: AttributeSet, - defaultStyleAttribute: Int - ) : super(context, attributes, defaultStyleAttribute) - - constructor( - context: Context, - attributes: AttributeSet, - defaultStyleAttribute: Int, - defaultStyleResource: Int - ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) - - private fun setBorder(drawables: StateDrawables) { - topLeftCorner.setImageDrawable(drawables.corner) - topRightCorner.setImageDrawable(drawables.corner) - bottomLeftCorner.setImageDrawable(drawables.corner) - bottomRightCorner.setImageDrawable(drawables.corner) - - leftBorder.setImageDrawable(drawables.verticalBorder) - rightBorder.setImageDrawable(drawables.verticalBorder) - - topBorder.setImageDrawable(drawables.horizontalBorder) - bottomBorder.setImageDrawable(drawables.horizontalBorder) - } -} 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/main/res/layout/account_history_entry.xml b/android/app/src/main/res/layout/account_history_entry.xml deleted file mode 100644 index 19ae478349..0000000000 --- a/android/app/src/main/res/layout/account_history_entry.xml +++ /dev/null @@ -1,22 +0,0 @@ -<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="@dimen/account_history_entry_height"> - <TextView android:id="@+id/label" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:focusable="true" - android:nextFocusRight="@id/remove" - android:background="@drawable/account_history_entry_background" - android:paddingHorizontal="12dp" - android:gravity="center_vertical" - android:textColor="@color/blue80" - android:textSize="@dimen/text_medium_plus" - android:textStyle="bold" /> - <ImageButton android:id="@+id/remove" - android:layout_width="@dimen/account_history_entry_height" - android:layout_height="@dimen/account_history_entry_height" - android:layout_gravity="end" - android:nextFocusLeft="@id/remove" - android:background="?android:attr/selectableItemBackground" - android:src="@drawable/account_history_remove" /> -</FrameLayout> diff --git a/android/app/src/main/res/layout/account_input.xml b/android/app/src/main/res/layout/account_input.xml deleted file mode 100644 index 96aa3c7c46..0000000000 --- a/android/app/src/main/res/layout/account_input.xml +++ /dev/null @@ -1,22 +0,0 @@ -<merge xmlns:android="http://schemas.android.com/apk/res/android"> - <EditText android:id="@+id/login_input" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_weight="1" - android:paddingHorizontal="12dp" - android:background="@drawable/account_input_background" - android:singleLine="true" - android:imeOptions="flagNoPersonalizedLearning" - android:textCursorDrawable="@drawable/text_input_cursor" - android:hint="@string/login_hint" - android:textColorHint="@color/blue40" - android:textColor="@color/blue" - android:textSize="@dimen/text_medium_plus" - android:textStyle="bold" /> - <ImageButton android:id="@+id/login_button" - android:layout_width="48dp" - android:layout_height="match_parent" - android:layout_weight="0" - android:background="@drawable/login_button_background" - android:src="@drawable/login_button_arrow" /> -</merge> diff --git a/android/app/src/main/res/layout/account_login.xml b/android/app/src/main/res/layout/account_login.xml deleted file mode 100644 index 5ada635027..0000000000 --- a/android/app/src/main/res/layout/account_login.xml +++ /dev/null @@ -1,16 +0,0 @@ -<merge xmlns:android="http://schemas.android.com/apk/res/android"> - <net.mullvad.mullvadvpn.ui.widget.AccountLoginBorder android:id="@+id/border" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_alignParentTop="true" - android:layout_alignParentBottom="true" /> - <net.mullvad.mullvadvpn.ui.widget.AccountInput android:id="@+id/input" - android:layout_width="match_parent" - android:layout_height="@dimen/account_login_input_height" - android:layout_alignParentTop="true" - android:orientation="horizontal" /> - <androidx.recyclerview.widget.RecyclerView android:id="@+id/history" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_below="@id/input" /> -</merge> diff --git a/android/app/src/main/res/layout/account_login_border.xml b/android/app/src/main/res/layout/account_login_border.xml deleted file mode 100644 index dab613ca57..0000000000 --- a/android/app/src/main/res/layout/account_login_border.xml +++ /dev/null @@ -1,59 +0,0 @@ -<merge xmlns:android="http://schemas.android.com/apk/res/android"> - <!-- corners --> - <ImageView android:id="@+id/top_left_corner" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_alignParentTop="true" - android:layout_alignParentStart="true" - android:src="@drawable/account_login_corner" /> - <ImageView android:id="@+id/top_right_corner" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_alignParentTop="true" - android:layout_alignParentEnd="true" - android:rotation="90" - android:src="@drawable/account_login_corner" /> - <ImageView android:id="@+id/bottom_right_corner" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_alignParentBottom="true" - android:layout_alignParentEnd="true" - android:rotation="180" - android:src="@drawable/account_login_corner" /> - <ImageView android:id="@+id/bottom_left_corner" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_alignParentBottom="true" - android:layout_alignParentStart="true" - android:rotation="270" - android:src="@drawable/account_login_corner" /> - <!-- sides --> - <ImageView android:id="@+id/left_border" - android:layout_width="@dimen/account_login_border_width" - android:layout_height="wrap_content" - android:layout_alignParentStart="true" - android:layout_below="@id/top_left_corner" - android:layout_above="@id/bottom_left_corner" - android:src="@drawable/account_login_border" /> - <ImageView android:id="@+id/right_border" - android:layout_width="@dimen/account_login_border_width" - android:layout_height="wrap_content" - android:layout_alignParentEnd="true" - android:layout_below="@id/top_right_corner" - android:layout_above="@id/bottom_right_corner" - android:src="@drawable/account_login_border" /> - <ImageView android:id="@+id/top_border" - android:layout_width="wrap_content" - android:layout_height="@dimen/account_login_border_width" - android:layout_toStartOf="@id/top_right_corner" - android:layout_toEndOf="@id/top_left_corner" - android:layout_alignParentTop="true" - android:src="@drawable/account_login_border" /> - <ImageView android:id="@+id/bottom_border" - android:layout_width="wrap_content" - android:layout_height="@dimen/account_login_border_width" - android:layout_toStartOf="@id/bottom_right_corner" - android:layout_toEndOf="@id/bottom_left_corner" - android:layout_alignParentBottom="true" - android:src="@drawable/account_login_border" /> -</merge> diff --git a/android/app/src/main/res/layout/login.xml b/android/app/src/main/res/layout/login.xml deleted file mode 100644 index 526dab3ca1..0000000000 --- a/android/app/src/main/res/layout/login.xml +++ /dev/null @@ -1,103 +0,0 @@ -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:mullvad="http://schemas.android.com/apk/res-auto" - android:id="@+id/scroll_area" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:fillViewport="true"> - <LinearLayout android:id="@+id/contents" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" - android:focusable="true" - android:focusableInTouchMode="true" - android:nextFocusForward="@id/login_input" - android:nextFocusDown="@id/login_input" - android:descendantFocusability="beforeDescendants"> - <requestFocus /> - <net.mullvad.mullvadvpn.ui.widget.HeaderBar android:id="@+id/header_bar" - android:layout_width="match_parent" - android:layout_height="wrap_content" /> - <LinearLayout android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_weight="1" - android:orientation="vertical" - android:paddingHorizontal="@dimen/side_margin" - android:paddingVertical="24dp"> - <Space android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1" /> - <FrameLayout android:layout_width="48dp" - android:layout_height="48dp" - android:layout_gravity="center_horizontal" - android:layout_marginBottom="30dp"> - <ProgressBar android:id="@+id/logging_in_status" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_gravity="center" - android:indeterminate="true" - android:indeterminateOnly="true" - android:indeterminateDuration="600" - android:indeterminateDrawable="@drawable/icon_spinner" - android:visibility="gone" /> - <ImageView android:id="@+id/logged_in_status" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_gravity="center" - android:src="@drawable/icon_success" - android:visibility="gone" /> - <ImageView android:id="@+id/login_fail_status" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_gravity="center" - android:src="@drawable/icon_fail" - android:visibility="gone" /> - </FrameLayout> - <TextView android:id="@+id/title" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_weight="0" - android:layout_marginBottom="7dp" - android:gravity="start" - android:textColor="@color/white" - android:textSize="@dimen/text_huge" - android:textStyle="bold" - android:text="@string/login_title" /> - <TextView android:id="@+id/subtitle" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_weight="0" - android:layout_marginBottom="8dp" - android:gravity="start" - android:textColor="@color/white80" - android:textSize="@dimen/text_small" - android:text="@string/login_description" /> - <net.mullvad.mullvadvpn.ui.widget.AccountLogin android:id="@+id/account_login" - android:layout_width="match_parent" - android:layout_height="@dimen/account_history_entry_height" /> - <Space android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="3" /> - </LinearLayout> - <LinearLayout android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_weight="0" - android:orientation="vertical" - android:paddingHorizontal="@dimen/side_margin" - android:paddingBottom="@dimen/screen_vertical_margin" - android:paddingTop="@dimen/button_separation" - android:background="@color/darkBlue"> - <TextView android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginBottom="8dp" - android:gravity="start" - android:textColor="@color/white80" - android:textSize="@dimen/text_small" - android:text="@string/dont_have_an_account" /> - <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/create_account" - android:layout_width="match_parent" - android:layout_height="wrap_content" - mullvad:buttonColor="blue" - mullvad:text="@string/create_account" /> - </LinearLayout> - </LinearLayout> -</ScrollView> 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, diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt index 399b702ddb..85f90d2270 100644 --- a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt @@ -2,7 +2,7 @@ package net.mullvad.mullvadvpn.test.common.interactor import android.content.Context import android.content.Intent -import android.widget.ImageButton +import android.widget.Button import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until @@ -54,7 +54,7 @@ class AppInteractor(private val device: UiDevice, private val targetContext: Con device.findObjectWithTimeout(By.clazz("android.widget.EditText")).apply { text = accountToken } - loginObject.parent.findObject(By.clazz(ImageButton::class.java)).click() + loginObject.parent.findObject(By.clazz(Button::class.java)).click() } fun ensureLoggedIn() { |
