summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-08-24 17:15:01 +0200
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-08-24 17:15:01 +0200
commit6aa85f643c08cd8409b2136fcc4673941c130730 (patch)
tree329c2e664dc222c4ba69b815fbed8415e9a20a69
parent3966954c5fbe487abd0863832afbeeaff54f19c0 (diff)
parentc1fa38ad2d36783c620de95fc2da08e2566ebf2b (diff)
downloadmullvadvpn-6aa85f643c08cd8409b2136fcc4673941c130730.tar.xz
mullvadvpn-6aa85f643c08cd8409b2136fcc4673941c130730.zip
Merge branch 'migrate-welcome-view-to-compose-droid-54'
-rw-r--r--CHANGELOG.md1
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt185
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt64
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoadingScreen.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt245
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt210
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt103
-rw-r--r--android/app/src/main/res/layout/welcome.xml66
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt165
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Color.kt1
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt1
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
)