diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-08-24 17:15:01 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-08-24 17:15:01 +0200 |
| commit | 6aa85f643c08cd8409b2136fcc4673941c130730 (patch) | |
| tree | 329c2e664dc222c4ba69b815fbed8415e9a20a69 | |
| parent | 3966954c5fbe487abd0863832afbeeaff54f19c0 (diff) | |
| parent | c1fa38ad2d36783c620de95fc2da08e2566ebf2b (diff) | |
| download | mullvadvpn-6aa85f643c08cd8409b2136fcc4673941c130730.tar.xz mullvadvpn-6aa85f643c08cd8409b2136fcc4673941c130730.zip | |
Merge branch 'migrate-welcome-view-to-compose-droid-54'
19 files changed, 808 insertions, 266 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 4044eae0a8..ee3d3dee38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Line wrap the file at 100 chars. Th - Migrate select Location view to compose. - Migrate settings view to compose. - Migrate account view to compose. +- Migrate welcome view to compose. ### Fixed #### Android diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt new file mode 100644 index 0000000000..051b16b6b1 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt @@ -0,0 +1,185 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import io.mockk.MockKAnnotations +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import net.mullvad.mullvadvpn.compose.state.WelcomeUiState +import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class WelcomeScreenTest { + @get:Rule val composeTestRule = createComposeRule() + + @Before + fun setup() { + MockKAnnotations.init(this) + } + + @Test + fun testDefaultState() { + // Arrange + composeTestRule.setContent { + WelcomeScreen( + showSitePayment = true, + uiState = WelcomeUiState(), + viewActions = MutableSharedFlow(), + onSitePaymentClick = {}, + onRedeemVoucherClick = {}, + onSettingsClick = {}, + onAccountClick = {}, + openConnectScreen = {} + ) + } + + // Assert + composeTestRule.apply { + onNodeWithText("Congrats!").assertExists() + onNodeWithText("Here’s your account number. Save it!").assertExists() + } + } + + @Test + fun testDisableSitePayment() { + // Arrange + composeTestRule.setContent { + WelcomeScreen( + showSitePayment = false, + uiState = WelcomeUiState(), + viewActions = MutableSharedFlow(), + onSitePaymentClick = {}, + onRedeemVoucherClick = {}, + onSettingsClick = {}, + onAccountClick = {}, + openConnectScreen = {} + ) + } + + // Assert + composeTestRule.apply { + onNodeWithText( + "Either buy credit on our website or redeem a voucher.", + substring = true + ) + .assertDoesNotExist() + onNodeWithText("Buy credit").assertDoesNotExist() + } + } + + @Test + fun testShowAccountNumber() { + // Arrange + val rawAccountNumber = "1111222233334444" + val expectedAccountNumber = "1111 2222 3333 4444" + composeTestRule.setContent { + WelcomeScreen( + showSitePayment = true, + uiState = WelcomeUiState(accountNumber = rawAccountNumber), + viewActions = MutableSharedFlow(), + onSitePaymentClick = {}, + onRedeemVoucherClick = {}, + onSettingsClick = {}, + onAccountClick = {}, + openConnectScreen = {} + ) + } + + // Assert + composeTestRule.apply { onNodeWithText(expectedAccountNumber).assertExists() } + } + + @Test + fun testOpenAccountView() { + // Arrange + composeTestRule.setContent { + WelcomeScreen( + showSitePayment = true, + uiState = WelcomeUiState(), + viewActions = MutableStateFlow(WelcomeViewModel.ViewAction.OpenAccountView("222")), + onSitePaymentClick = {}, + onRedeemVoucherClick = {}, + onSettingsClick = {}, + onAccountClick = {}, + openConnectScreen = {} + ) + } + + // Assert + composeTestRule.apply { onNodeWithText("Congrats!").assertDoesNotExist() } + } + + @Test + fun testOpenConnectScreen() { + // Arrange + val mockClickListener: () -> Unit = mockk(relaxed = true) + composeTestRule.setContent { + WelcomeScreen( + showSitePayment = true, + uiState = WelcomeUiState(), + viewActions = MutableStateFlow(WelcomeViewModel.ViewAction.OpenConnectScreen), + onSitePaymentClick = {}, + onRedeemVoucherClick = {}, + onSettingsClick = {}, + onAccountClick = {}, + openConnectScreen = mockClickListener + ) + } + + // Assert + verify(exactly = 1) { mockClickListener.invoke() } + } + + @Test + fun testClickSitePaymentButton() { + // Arrange + val mockClickListener: () -> Unit = mockk(relaxed = true) + composeTestRule.setContent { + WelcomeScreen( + showSitePayment = true, + uiState = WelcomeUiState(), + viewActions = MutableStateFlow(WelcomeViewModel.ViewAction.OpenConnectScreen), + onSitePaymentClick = mockClickListener, + onRedeemVoucherClick = {}, + onSettingsClick = {}, + onAccountClick = {}, + openConnectScreen = {} + ) + } + + // Act + composeTestRule.apply { onNodeWithText("Buy credit").performClick() } + + // Assert + verify(exactly = 1) { mockClickListener.invoke() } + } + + @Test + fun testClickRedeemVoucher() { + // Arrange + val mockClickListener: () -> Unit = mockk(relaxed = true) + composeTestRule.setContent { + WelcomeScreen( + showSitePayment = true, + uiState = WelcomeUiState(), + viewActions = MutableStateFlow(WelcomeViewModel.ViewAction.OpenConnectScreen), + onSitePaymentClick = {}, + onRedeemVoucherClick = mockClickListener, + onSettingsClick = {}, + onAccountClick = {}, + openConnectScreen = {} + ) + } + + // Act + composeTestRule.apply { onNodeWithText("Redeem voucher").performClick() } + + // Assert + verify(exactly = 1) { mockClickListener.invoke() } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt index fb9e3b380b..2cfe0c65d9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt @@ -3,11 +3,12 @@ package net.mullvad.mullvadvpn.compose.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -22,14 +23,16 @@ import me.onebone.toolbar.CollapsingToolbarScaffoldState import me.onebone.toolbar.CollapsingToolbarScope import me.onebone.toolbar.ExperimentalToolbarApi import me.onebone.toolbar.ScrollStrategy +import net.mullvad.mullvadvpn.lib.theme.AlphaTopBar -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ScaffoldWithTopBar( topBarColor: Color, statusBarColor: Color, navigationBarColor: Color, + iconTintColor: Color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar), onSettingsClicked: (() -> Unit)?, + onAccountClicked: (() -> Unit)?, isIconAndLogoVisible: Boolean = true, content: @Composable (PaddingValues) -> Unit, ) { @@ -41,7 +44,9 @@ fun ScaffoldWithTopBar( topBar = { TopBar( backgroundColor = topBarColor, + iconTintColor = iconTintColor, onSettingsClicked = onSettingsClicked, + onAccountClicked = onAccountClicked, isIconAndLogoVisible = isIconAndLogoVisible ) }, @@ -81,7 +86,7 @@ fun CollapsableAwareToolbarScaffold( toolbarModifier = toolbarModifier, toolbar = toolbar, body = { - var bodyHeight by remember { mutableStateOf(0) } + var bodyHeight by remember { mutableIntStateOf(0) } BoxWithConstraints( modifier = Modifier.onGloballyPositioned { bodyHeight = it.size.height } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt index 3e42faba80..234bcbace8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt @@ -9,60 +9,92 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens @Preview @Composable private fun PreviewTopBar() { - TopBar(backgroundColor = colorResource(R.color.blue), onSettingsClicked = {}) + AppTheme { + TopBar( + backgroundColor = MaterialTheme.colorScheme.inversePrimary, + iconTintColor = MaterialTheme.colorScheme.onPrimary, + onSettingsClicked = null, + onAccountClicked = {} + ) + } } @Composable fun TopBar( backgroundColor: Color, onSettingsClicked: (() -> Unit)?, + onAccountClicked: (() -> Unit)?, modifier: Modifier = Modifier, + iconTintColor: Color, isIconAndLogoVisible: Boolean = true ) { ConstraintLayout( modifier = Modifier.fillMaxWidth() - .height(dimensionResource(id = R.dimen.top_bar_height)) + .height(Dimens.topBarHeight) .background(backgroundColor) .then(modifier), ) { - val (logo, appName, settingsIcon) = createRefs() + val (logo, appName, accountIcon, settingsIcon) = createRefs() if (isIconAndLogoVisible) { Image( painter = painterResource(id = R.drawable.logo_icon), contentDescription = null, // No meaningful user info or action. modifier = - Modifier.width(44.dp).height(44.dp).constrainAs(logo) { - centerVerticallyTo(parent) - start.linkTo(parent.start, margin = 16.dp) - } + Modifier.padding(start = Dimens.mediumPadding) + .width(Dimens.buttonHeight) + .height(Dimens.buttonHeight) + .constrainAs(logo) { + centerVerticallyTo(parent) + start.linkTo(parent.start) + } ) Icon( painter = painterResource(id = R.drawable.logo_text), - tint = colorResource(id = R.color.white80), + tint = iconTintColor, contentDescription = null, // No meaningful user info or action. modifier = - Modifier.height(16.dp).constrainAs(appName) { - centerVerticallyTo(parent) - start.linkTo(logo.end, margin = 8.dp) - } + Modifier.padding(start = Dimens.smallPadding) + .height(Dimens.mediumPadding) + .constrainAs(appName) { + centerVerticallyTo(parent) + start.linkTo(logo.end) + } + ) + } + + if (onAccountClicked != null) { + Image( + painter = painterResource(R.drawable.icon_account), + contentDescription = stringResource(id = R.string.settings_account), + modifier = + Modifier.clickable { onAccountClicked() } + .fillMaxHeight() + .padding(horizontal = Dimens.mediumPadding) + .constrainAs(accountIcon) { + if (onSettingsClicked != null) { + end.linkTo(settingsIcon.start) + } else { + end.linkTo(parent.end) + } + } ) } @@ -73,7 +105,7 @@ fun TopBar( modifier = Modifier.clickable { onSettingsClicked() } .fillMaxHeight() - .padding(horizontal = 16.dp) + .padding(horizontal = Dimens.mediumPadding) .constrainAs(settingsIcon) { end.linkTo(parent.end) } ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt index 160ae532c8..48e7ef2a0b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt @@ -95,7 +95,8 @@ fun DeviceListScreen( topBarColor = topColor, statusBarColor = topColor, navigationBarColor = colorResource(id = R.color.darkBlue), - onSettingsClicked = onSettingsClicked + onSettingsClicked = onSettingsClicked, + onAccountClicked = null, ) { ConstraintLayout( modifier = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt index 68a445f3a9..004ce137cd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt @@ -54,6 +54,7 @@ fun DeviceRevokedScreen( statusBarColor = topColor, navigationBarColor = colorResource(id = R.color.darkBlue), onSettingsClicked = onSettingsClicked, + onAccountClicked = null ) { ConstraintLayout( modifier = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoadingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoadingScreen.kt index 6c5b5324ec..0b5fc8c245 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoadingScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoadingScreen.kt @@ -37,6 +37,7 @@ fun LoadingScreen(onSettingsCogClicked: () -> Unit = {}) { statusBarColor = backgroundColor, navigationBarColor = backgroundColor, onSettingsClicked = onSettingsCogClicked, + onAccountClicked = null, isIconAndLogoVisible = false, content = { Box( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt index c7abfe72fc..2ae1b0893f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt @@ -50,6 +50,7 @@ fun PrivacyDisclaimerScreen( topBarColor = topColor, statusBarColor = topColor, navigationBarColor = colorResource(id = R.color.darkBlue), + onAccountClicked = null, onSettingsClicked = null ) { ConstraintLayout( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt new file mode 100644 index 0000000000..038eb60663 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt @@ -0,0 +1,245 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.ActionButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.state.WelcomeUiState +import net.mullvad.mullvadvpn.lib.common.util.SdkUtils +import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces +import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser +import net.mullvad.mullvadvpn.lib.theme.AlphaTopBar +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.ui.extension.copyToClipboard +import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel + +@Preview +@Composable +private fun PreviewWelcomeScreen() { + AppTheme { + WelcomeScreen( + showSitePayment = true, + uiState = WelcomeUiState(accountNumber = "4444555566667777"), + viewActions = MutableSharedFlow<WelcomeViewModel.ViewAction>().asSharedFlow(), + onSitePaymentClick = {}, + onRedeemVoucherClick = {}, + onSettingsClick = {}, + onAccountClick = {}, + openConnectScreen = {} + ) + } +} + +@Composable +fun WelcomeScreen( + showSitePayment: Boolean, + uiState: WelcomeUiState, + viewActions: SharedFlow<WelcomeViewModel.ViewAction>, + onSitePaymentClick: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onSettingsClick: () -> Unit, + onAccountClick: () -> Unit, + openConnectScreen: () -> Unit +) { + val context = LocalContext.current + LaunchedEffect(key1 = Unit) { + viewActions.collect { viewAction -> + when (viewAction) { + is WelcomeViewModel.ViewAction.OpenAccountView -> + context.openAccountPageInBrowser(viewAction.token) + WelcomeViewModel.ViewAction.OpenConnectScreen -> openConnectScreen() + } + } + } + val scrollState = rememberScrollState() + ScaffoldWithTopBar( + topBarColor = + if (uiState.tunnelState.isSecured()) { + MaterialTheme.colorScheme.inversePrimary + } else { + MaterialTheme.colorScheme.error + }, + statusBarColor = + if (uiState.tunnelState.isSecured()) { + MaterialTheme.colorScheme.inversePrimary + } else { + MaterialTheme.colorScheme.error + }, + navigationBarColor = MaterialTheme.colorScheme.background, + iconTintColor = + if (uiState.tunnelState.isSecured()) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onError + } + .copy(alpha = AlphaTopBar), + onSettingsClicked = onSettingsClick, + onAccountClicked = onAccountClick + ) { + Column( + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.Start, + modifier = + Modifier.fillMaxSize() + .verticalScroll(scrollState) + .drawVerticalScrollbar(scrollState) + .background(color = MaterialTheme.colorScheme.primary) + .padding(it) + ) { + Text( + text = stringResource(id = R.string.congrats), + modifier = + Modifier.padding( + top = Dimens.screenVerticalMargin, + start = Dimens.sideMargin, + end = Dimens.sideMargin + ), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onPrimary + ) + Text( + text = stringResource(id = R.string.here_is_your_account_number), + modifier = + Modifier.padding( + vertical = Dimens.smallPadding, + horizontal = Dimens.sideMargin + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary + ) + Text( + text = uiState.accountNumber?.groupWithSpaces() ?: "", + modifier = + Modifier.fillMaxWidth() + .wrapContentHeight() + .then( + uiState.accountNumber?.let { + Modifier.clickable { + context.copyToClipboard( + content = uiState.accountNumber, + clipboardLabel = + context.getString(R.string.mullvad_account_number) + ) + SdkUtils.showCopyToastIfNeeded( + context, + context.getString(R.string.copied_mullvad_account_number) + ) + } + } + ?: Modifier + ) + .padding(vertical = Dimens.smallPadding, horizontal = Dimens.sideMargin), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onPrimary + ) + Text( + text = + buildString { + append(stringResource(id = R.string.pay_to_start_using)) + if (showSitePayment) { + append(" ") + append(stringResource(id = R.string.add_time_to_account)) + } + }, + modifier = + Modifier.padding( + top = Dimens.smallPadding, + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.verticalSpace + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.weight(1f)) + // Payment button area + Column( + modifier = + Modifier.fillMaxWidth() + .padding(top = Dimens.mediumPadding) + .background(color = MaterialTheme.colorScheme.background) + ) { + Spacer(modifier = Modifier.padding(top = Dimens.screenVerticalMargin)) + if (showSitePayment) { + ActionButton( + onClick = onSitePaymentClick, + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.screenVerticalMargin + ), + colors = + ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(id = R.string.buy_credit), + textAlign = TextAlign.Center, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.Center) + ) + Image( + painter = painterResource(id = R.drawable.icon_extlink), + contentDescription = null, + modifier = + Modifier.align(Alignment.CenterEnd) + .padding(horizontal = Dimens.smallPadding) + ) + } + } + } + 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 + ) + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt new file mode 100644 index 0000000000..b8a12ce4ae --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.model.TunnelState + +data class WelcomeUiState( + val tunnelState: TunnelState = TunnelState.Disconnected, + val accountNumber: String? = null +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt new file mode 100644 index 0000000000..dff48b6228 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.constant + +const val ACCOUNT_EXPIRY_POLL_INTERVAL: Long = 15 /* s */ * 1000 /* ms */ diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 76060b8340..047ff2a26e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -32,6 +32,7 @@ import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel +import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel import org.apache.commons.validator.routines.InetAddressValidator import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext @@ -91,6 +92,7 @@ val uiModule = module { viewModel { SelectLocationViewModel(get()) } viewModel { SettingsViewModel(get(), get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } + viewModel { WelcomeViewModel(get(), get(), get()) } } const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt index 954e9dcedf..9b5eb395ad 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL import net.mullvad.mullvadvpn.lib.common.constant.BuildTypes import net.mullvad.mullvadvpn.lib.common.util.JobTracker import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser @@ -137,7 +138,7 @@ class OutOfTimeFragment : BaseFragment() { private fun CoroutineScope.launchExpiryPolling() = launch { while (true) { accountRepository.fetchAccountExpiry() - delay(POLL_INTERVAL) + delay(ACCOUNT_EXPIRY_POLL_INTERVAL) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt index a995e4f5b4..706bbc4858 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt @@ -1,194 +1,52 @@ package net.mullvad.mullvadvpn.ui.fragment -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.TextView -import android.widget.Toast -import androidx.core.view.isVisible -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.launch +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.screen.WelcomeScreen import net.mullvad.mullvadvpn.lib.common.constant.BuildTypes -import net.mullvad.mullvadvpn.lib.common.util.JobTracker -import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.repository.DeviceRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache -import net.mullvad.mullvadvpn.ui.widget.HeaderBar -import net.mullvad.mullvadvpn.ui.widget.RedeemVoucherButton -import net.mullvad.mullvadvpn.ui.widget.SitePaymentButton -import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS -import net.mullvad.mullvadvpn.util.addDebounceForUnknownState -import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier -import org.joda.time.DateTime -import org.koin.android.ext.android.inject - -val POLL_INTERVAL: Long = 15 /* s */ * 1000 /* ms */ +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel class WelcomeFragment : BaseFragment() { - // Injected dependencies - private val accountRepository: AccountRepository by inject() - private val deviceRepository: DeviceRepository by inject() - private val serviceConnectionManager: ServiceConnectionManager by inject() - - private lateinit var accountLabel: TextView - private lateinit var headerBar: HeaderBar - private lateinit var sitePaymentButton: SitePaymentButton - - @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 by viewModel<WelcomeViewModel>() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - val view = inflater.inflate(R.layout.welcome, container, false) - - headerBar = - view.findViewById<HeaderBar>(R.id.header_bar).apply { - tunnelState = TunnelState.Disconnected - } - - accountLabel = - view.findViewById<TextView>(R.id.account_number).apply { - setOnClickListener { copyAccountTokenToClipboard() } - } - - view.findViewById<TextView>(R.id.pay_to_start_using).text = buildString { - append(requireActivity().getString(R.string.pay_to_start_using)) - if (BuildConfig.BUILD_TYPE != BuildTypes.RELEASE) { - append(" ") - append(requireActivity().getString(R.string.add_time_to_account)) - } - } - - sitePaymentButton = - view.findViewById<SitePaymentButton>(R.id.site_payment).apply { - newAccount = true - - setOnClickAction("openAccountPageInBrowser", jobTracker) { - setEnabled(false) - serviceConnectionManager.authTokenCache()?.fetchAuthToken()?.let { token -> - context.openAccountPageInBrowser(token) - } - setEnabled(true) - } - } - - sitePaymentButton.isVisible = BuildTypes.RELEASE != BuildConfig.BUILD_TYPE - - view.findViewById<RedeemVoucherButton>(R.id.redeem_voucher).apply { - prepare(parentFragmentManager, jobTracker) - } - - return view - } - - override fun onStop() { - jobTracker.cancelAllJobs() - super.onStop() - } - - private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch { - repeatOnLifecycle(Lifecycle.State.RESUMED) { - launchUpdateAccountNumberOnDeviceChanges() - launchAdvanceToConnectViewOnExpiryExtended() - launchExpiryPolling() - launchTunnelStateSubscription() - } - } - - private fun CoroutineScope.launchUpdateAccountNumberOnDeviceChanges() = launch { - deviceRepository.deviceState - .debounce { it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS) } - .collect { state -> updateAccountNumber(state.token()) } - } - - private fun CoroutineScope.launchAdvanceToConnectViewOnExpiryExtended() = launch { - accountRepository.accountExpiryState.collect { checkExpiry(it.date()) } - } - - private fun CoroutineScope.launchExpiryPolling() = launch { - while (true) { - accountRepository.fetchAccountExpiry() - delay(POLL_INTERVAL) - } - } - - private fun CoroutineScope.launchTunnelStateSubscription() = launch { - serviceConnectionManager.connectionState - .flatMapLatest { state -> - if (state is ServiceConnectionState.ConnectedReady) { - callbackFlowFromNotifier(state.container.connectionProxy.onStateChange) - } else { - emptyFlow() + return inflater.inflate(R.layout.fragment_compose, container, false).apply { + findViewById<ComposeView>(R.id.compose_view).setContent { + AppTheme { + val state = vm.uiState.collectAsState().value + WelcomeScreen( + showSitePayment = BuildTypes.RELEASE != BuildConfig.BUILD_TYPE, + uiState = state, + viewActions = vm.viewActions, + onSitePaymentClick = vm::onSitePaymentClick, + onRedeemVoucherClick = ::openRedeemVoucherFragment, + onSettingsClick = ::openSettingsView, + onAccountClick = ::openAccountView, + openConnectScreen = ::advanceToConnectScreen + ) } } - .collect { state -> updateUiForTunnelState(state) } - } - - private fun updateUiForTunnelState(tunnelState: TunnelState) { - headerBar.tunnelState = tunnelState - sitePaymentButton.isEnabled = tunnelState is TunnelState.Disconnected - } - - private fun updateAccountNumber(rawAccountNumber: String?) { - val accountText = rawAccountNumber?.let { account -> addSpacesToAccountText(account) } - - accountLabel.text = accountText ?: "" - accountLabel.setEnabled(accountText != null && accountText.length > 0) - } - - private fun addSpacesToAccountText(account: String): String { - val length = account.length - - if (length == 0) { - return "" - } else { - val numParts = (length - 1) / 4 + 1 - - val parts = - Array(numParts) { index -> - val startIndex = index * 4 - val endIndex = minOf(startIndex + 4, length) - - account.substring(startIndex, endIndex) - } - - return parts.joinToString(" ") } } - private fun checkExpiry(maybeExpiry: DateTime?) { - maybeExpiry?.let { expiry -> - val tomorrow = DateTime.now().plusHours(20) - - if (expiry.isAfter(tomorrow)) { - advanceToConnectScreen() - } - } + private fun openRedeemVoucherFragment() { + val transaction = parentFragmentManager.beginTransaction() + transaction.addToBackStack(null) + RedeemVoucherDialogFragment().show(transaction, null) } private fun advanceToConnectScreen() { @@ -198,17 +56,11 @@ class WelcomeFragment : BaseFragment() { } } - private fun copyAccountTokenToClipboard() { - val accountToken = accountLabel.text - val clipboardLabel = resources.getString(R.string.mullvad_account_number) - val toastMessage = resources.getString(R.string.copied_mullvad_account_number) - - val context = requireActivity() - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clipData = ClipData.newPlainText(clipboardLabel, accountToken) - - clipboard.setPrimaryClip(clipData) + private fun openSettingsView() { + (context as? MainActivity)?.openSettings() + } - Toast.makeText(context, toastMessage, Toast.LENGTH_SHORT).show() + private fun openAccountView() { + (context as? MainActivity)?.openAccount() } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt new file mode 100644 index 0000000000..eaba6ad784 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt @@ -0,0 +1,103 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.state.WelcomeUiState +import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache +import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS +import net.mullvad.mullvadvpn.util.addDebounceForUnknownState +import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier +import org.joda.time.DateTime + +@OptIn(FlowPreview::class) +class WelcomeViewModel( + private val accountRepository: AccountRepository, + private val deviceRepository: DeviceRepository, + private val serviceConnectionManager: ServiceConnectionManager, + private val pollAccountExpiry: Boolean = true +) : ViewModel() { + + private val _viewActions = MutableSharedFlow<ViewAction>(extraBufferCapacity = 1) + val viewActions = _viewActions.asSharedFlow() + + val uiState = + serviceConnectionManager.connectionState + .flatMapLatest { state -> + if (state is ServiceConnectionState.ConnectedReady) { + flowOf(state.container) + } else { + emptyFlow() + } + } + .flatMapLatest { serviceConnection -> + combine( + serviceConnection.connectionProxy.tunnelUiStateFlow(), + deviceRepository.deviceState.debounce { + it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS) + } + ) { tunnelState, deviceState -> + WelcomeUiState(tunnelState = tunnelState, accountNumber = deviceState.token()) + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), WelcomeUiState()) + + init { + viewModelScope.launch { + accountRepository.accountExpiryState.collectLatest { accountExpiry -> + accountExpiry.date()?.let { expiry -> + val tomorrow = DateTime.now().plusHours(20) + + if (expiry.isAfter(tomorrow)) { + _viewActions.tryEmit(ViewAction.OpenConnectScreen) + } + } + } + } + viewModelScope.launch { + while (pollAccountExpiry) { + accountRepository.fetchAccountExpiry() + delay(ACCOUNT_EXPIRY_POLL_INTERVAL) + } + } + } + + private fun ConnectionProxy.tunnelUiStateFlow(): Flow<TunnelState> = + callbackFlowFromNotifier(this.onUiStateChange) + + fun onSitePaymentClick() { + viewModelScope.launch { + _viewActions.tryEmit( + ViewAction.OpenAccountView( + serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: "" + ) + ) + } + } + + sealed interface ViewAction { + data class OpenAccountView(val token: String) : ViewAction + + data object OpenConnectScreen : ViewAction + } +} diff --git a/android/app/src/main/res/layout/welcome.xml b/android/app/src/main/res/layout/welcome.xml deleted file mode 100644 index e1c887ab96..0000000000 --- a/android/app/src/main/res/layout/welcome.xml +++ /dev/null @@ -1,66 +0,0 @@ -<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:mullvad="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent"> - <net.mullvad.mullvadvpn.ui.widget.HeaderBar android:id="@+id/header_bar" - android:layout_width="match_parent" - android:layout_height="wrap_content" /> - <ScrollView android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_alignParentBottom="true" - android:layout_below="@id/header_bar" - android:fillViewport="true"> - <LinearLayout android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical"> - <TextView android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginHorizontal="@dimen/side_margin" - android:layout_marginTop="@dimen/screen_vertical_margin" - android:textColor="@color/white" - android:textSize="@dimen/text_huge" - android:textStyle="bold" - android:text="@string/congrats" /> - <TextView android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginHorizontal="@dimen/side_margin" - android:layout_marginTop="8dp" - android:layout_marginBottom="11dp" - android:textColor="@color/white" - android:textSize="@dimen/text_small" - android:text="@string/here_is_your_account_number" /> - <TextView android:id="@+id/account_number" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingHorizontal="@dimen/side_margin" - android:paddingVertical="11dp" - android:clickable="true" - android:focusable="true" - android:background="?android:attr/selectableItemBackground" - android:textColor="@color/white" - android:textSize="@dimen/text_big" - android:textStyle="bold" - android:text="" /> - <TextView android:id="@+id/pay_to_start_using" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginHorizontal="@dimen/side_margin" - android:layout_marginTop="11dp" - android:layout_marginBottom="@dimen/vertical_space" - android:textColor="@color/white" - android:textSize="@dimen/text_small" /> - <Space android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1" /> - <LinearLayout android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - android:orientation="vertical" - android:paddingTop="@dimen/button_separation" - android:paddingBottom="@dimen/screen_vertical_margin" - android:background="@color/darkBlue"> - <include layout="@layout/payment_buttons" /> - </LinearLayout> - </LinearLayout> - </ScrollView> -</RelativeLayout> diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt new file mode 100644 index 0000000000..42a44a07f1 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt @@ -0,0 +1,165 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.viewModelScope +import app.cash.turbine.test +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.TestCoroutineRule +import net.mullvad.mullvadvpn.compose.state.WelcomeUiState +import net.mullvad.mullvadvpn.model.AccountAndDevice +import net.mullvad.mullvadvpn.model.AccountExpiry +import net.mullvad.mullvadvpn.model.DeviceState +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache +import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache +import net.mullvad.talpid.util.EventNotifier +import org.joda.time.DateTime +import org.joda.time.ReadableInstant +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class WelcomeViewModelTest { + @get:Rule val testCoroutineRule = TestCoroutineRule() + + private val serviceConnectionState = + MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected) + private val deviceState = MutableStateFlow<DeviceState>(DeviceState.Initial) + private val accountExpiryState = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing) + + // Service connections + private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() + private val mockConnectionProxy: ConnectionProxy = mockk() + + // Event notifiers + private val eventNotifierTunnelUiState = EventNotifier<TunnelState>(TunnelState.Disconnected) + + private val mockAccountRepository: AccountRepository = mockk() + private val mockDeviceRepository: DeviceRepository = mockk() + private val mockServiceConnectionManager: ServiceConnectionManager = mockk() + + private lateinit var viewModel: WelcomeViewModel + + @Before + fun setUp() { + mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS) + + every { mockDeviceRepository.deviceState } returns deviceState + + every { mockServiceConnectionManager.connectionState } returns serviceConnectionState + + every { mockServiceConnectionContainer.connectionProxy } returns mockConnectionProxy + + every { mockConnectionProxy.onUiStateChange } returns eventNotifierTunnelUiState + + every { mockAccountRepository.accountExpiryState } returns accountExpiryState + + viewModel = + WelcomeViewModel( + accountRepository = mockAccountRepository, + deviceRepository = mockDeviceRepository, + serviceConnectionManager = mockServiceConnectionManager, + pollAccountExpiry = false + ) + } + + @After + fun tearDown() { + viewModel.viewModelScope.coroutineContext.cancel() + unmockkAll() + } + + @Test + fun testSitePaymentClick() = + runTest(testCoroutineRule.testDispatcher) { + // Arrange + val mockToken = "4444 5555 6666 7777" + val mockAuthTokenCache: AuthTokenCache = mockk(relaxed = true) + every { mockServiceConnectionManager.authTokenCache() } returns mockAuthTokenCache + coEvery { mockAuthTokenCache.fetchAuthToken() } returns mockToken + + // Act, Assert + viewModel.viewActions.test { + viewModel.onSitePaymentClick() + val action = awaitItem() + assertIs<WelcomeViewModel.ViewAction.OpenAccountView>(action) + assertEquals(mockToken, action.token) + } + } + + @Test + fun testUpdateTunnelState() = + runTest(testCoroutineRule.testDispatcher) { + // Arrange + val tunnelUiStateTestItem = TunnelState.Connected(mockk(), mockk()) + + // Act, Assert + viewModel.uiState.test { + assertEquals(WelcomeUiState(), awaitItem()) + serviceConnectionState.value = + ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + eventNotifierTunnelUiState.notify(tunnelUiStateTestItem) + val result = awaitItem() + assertEquals(tunnelUiStateTestItem, result.tunnelState) + } + } + + @Test + fun testUpdateAccountNumber() = + runTest(testCoroutineRule.testDispatcher) { + // Arrange + val expectedAccountNumber = "4444555566667777" + + // Act, Assert + viewModel.uiState.test { + assertEquals(WelcomeUiState(), awaitItem()) + serviceConnectionState.value = + ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) + deviceState.value = + DeviceState.LoggedIn( + accountAndDevice = + AccountAndDevice( + account_token = expectedAccountNumber, + device = mockk() + ) + ) + val result = awaitItem() + assertEquals(expectedAccountNumber, result.accountNumber) + } + } + + @Test + fun testOpenConnectScreen() = + runTest(testCoroutineRule.testDispatcher) { + // Arrange + val mockExpiryDate: DateTime = mockk() + every { mockExpiryDate.isAfter(any<ReadableInstant>()) } returns true + + // Act, Assert + viewModel.viewActions.test { + accountExpiryState.value = AccountExpiry.Available(mockExpiryDate) + val action = awaitItem() + assertIs<WelcomeViewModel.ViewAction.OpenConnectScreen>(action) + } + } + + companion object { + private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS = + "net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt" + } +} diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Color.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Color.kt index 391189c406..639b183e86 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Color.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Color.kt @@ -61,4 +61,5 @@ const val Alpha20 = 0.2f const val AlphaInactive = 0.4f const val AlphaDescription = 0.6f const val AlphaDisconnectButton = 0.6f +const val AlphaTopBar = 0.8f const val AlphaInvisible = 0f 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 fa922eac9e..9013fb6a28 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 @@ -44,6 +44,7 @@ data class Dimensions( 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, val verticalSpacer: Dp = 1.dp ) |
