summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-09-26 11:32:38 +0200
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-09-26 11:32:38 +0200
commit29bb3f728840f3766728000da5122f07ff3f0fdb (patch)
treef4b5f4ac857a7bdaef6f7e8ada9e7fabc98522cc /android
parent77b74239c60356a23546a49726a7337d84b69d7b (diff)
parentf52aa1729fbabe7213c7c2f3c4d9824704a512ca (diff)
downloadmullvadvpn-29bb3f728840f3766728000da5122f07ff3f0fdb.tar.xz
mullvadvpn-29bb3f728840f3766728000da5122f07ff3f0fdb.zip
Merge branch 'migrate-out-of-time-view-to-compose-droid-55'
Diffstat (limited to 'android')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt172
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ExternalActionButton.kt70
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/RedeemVoucherButton.kt54
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt59
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt233
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt46
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt5
-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.kt210
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/RedeemVoucherButton.kt33
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SitePaymentButton.kt27
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt24
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt97
-rw-r--r--android/app/src/main/res/layout/out_of_time.xml59
-rw-r--r--android/app/src/main/res/layout/payment_buttons.xml15
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt149
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt1
18 files changed, 896 insertions, 372 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt
new file mode 100644
index 0000000000..a97e587c8c
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt
@@ -0,0 +1,172 @@
+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.OutOfTimeUiState
+import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class OutOfTimeScreenTest {
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @Test
+ fun testDisableSitePayment() {
+ // Arrange
+ composeTestRule.setContent {
+ OutOfTimeScreen(
+ showSitePayment = false,
+ uiState = OutOfTimeUiState(),
+ viewActions = MutableSharedFlow(),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onDisconnectClick = {}
+ )
+ }
+
+ // Assert
+ composeTestRule.apply {
+ onNodeWithText(
+ "Either buy credit on our website or redeem a voucher.",
+ substring = true
+ )
+ .assertDoesNotExist()
+ onNodeWithText("Buy credit").assertDoesNotExist()
+ }
+ }
+
+ @Test
+ fun testOpenAccountView() {
+ // Arrange
+ composeTestRule.setContent {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState = OutOfTimeUiState(),
+ viewActions =
+ MutableStateFlow(OutOfTimeViewModel.ViewAction.OpenAccountView("222")),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onDisconnectClick = {}
+ )
+ }
+
+ // Assert
+ composeTestRule.apply { onNodeWithText("Congrats!").assertDoesNotExist() }
+ }
+
+ @Test
+ fun testOpenConnectScreen() {
+ // Arrange
+ val mockClickListener: () -> Unit = mockk(relaxed = true)
+ composeTestRule.setContent {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState = OutOfTimeUiState(),
+ viewActions = MutableStateFlow(OutOfTimeViewModel.ViewAction.OpenConnectScreen),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = mockClickListener,
+ onDisconnectClick = {}
+ )
+ }
+
+ // Assert
+ verify(exactly = 1) { mockClickListener.invoke() }
+ }
+
+ @Test
+ fun testClickSitePaymentButton() {
+ // Arrange
+ val mockClickListener: () -> Unit = mockk(relaxed = true)
+ composeTestRule.setContent {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState = OutOfTimeUiState(),
+ viewActions = MutableSharedFlow(),
+ onSitePaymentClick = mockClickListener,
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onDisconnectClick = {}
+ )
+ }
+
+ // 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 {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState = OutOfTimeUiState(),
+ viewActions = MutableSharedFlow(),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = mockClickListener,
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onDisconnectClick = {}
+ )
+ }
+
+ // Act
+ composeTestRule.apply { onNodeWithText("Redeem voucher").performClick() }
+
+ // Assert
+ verify(exactly = 1) { mockClickListener.invoke() }
+ }
+
+ @Test
+ fun testClickDisconnect() {
+ // Arrange
+ val mockClickListener: () -> Unit = mockk(relaxed = true)
+ composeTestRule.setContent {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState = OutOfTimeUiState(tunnelState = TunnelState.Connecting(null, null)),
+ viewActions = MutableSharedFlow(),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onDisconnectClick = mockClickListener
+ )
+ }
+
+ // Act
+ composeTestRule.apply { onNodeWithText("Disconnect").performClick() }
+
+ // Assert
+ verify(exactly = 1) { mockClickListener.invoke() }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ExternalActionButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ExternalActionButton.kt
new file mode 100644
index 0000000000..d3945f7069
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ExternalActionButton.kt
@@ -0,0 +1,70 @@
+package net.mullvad.mullvadvpn.compose.button
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.constraintlayout.compose.ConstraintLayout
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.lib.theme.AlphaDisabled
+import net.mullvad.mullvadvpn.lib.theme.AlphaVisible
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+@Preview
+@Composable
+private fun PreviewExternalActionButton() {
+ AppTheme {
+ ExternalActionButton(onClick = {}, colors = ButtonDefaults.buttonColors(), text = "Button")
+ }
+}
+
+@Composable
+fun ExternalActionButton(
+ onClick: () -> Unit,
+ colors: ButtonColors,
+ text: String,
+ modifier: Modifier = Modifier,
+ isEnabled: Boolean = true,
+) {
+ ActionButton(
+ onClick = onClick,
+ colors = colors,
+ modifier = modifier,
+ isEnabled = isEnabled,
+ ) {
+ ConstraintLayout(modifier = Modifier.fillMaxSize()) {
+ val (title, logo) = createRefs()
+ Text(
+ text = text,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier =
+ Modifier.constrainAs(title) {
+ end.linkTo(logo.start)
+ centerTo(parent)
+ }
+ )
+ Image(
+ painter = painterResource(id = R.drawable.icon_extlink),
+ contentDescription = null,
+ modifier =
+ Modifier.constrainAs(logo) {
+ centerVerticallyTo(parent)
+ end.linkTo(parent.end)
+ }
+ .padding(horizontal = Dimens.smallPadding)
+ .alpha(if (isEnabled) AlphaVisible else AlphaDisabled)
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/RedeemVoucherButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/RedeemVoucherButton.kt
new file mode 100644
index 0000000000..41ab2cf876
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/RedeemVoucherButton.kt
@@ -0,0 +1,54 @@
+package net.mullvad.mullvadvpn.compose.button
+
+import androidx.compose.material3.ButtonDefaults
+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.graphics.compositeOver
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.component.SpacedColumn
+import net.mullvad.mullvadvpn.lib.theme.AlphaDisabled
+import net.mullvad.mullvadvpn.lib.theme.AlphaInactive
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+
+@Preview
+@Composable
+private fun PreviewRedeemVoucherButton() {
+ AppTheme {
+ SpacedColumn {
+ RedeemVoucherButton(onClick = {}, isEnabled = true)
+ RedeemVoucherButton(onClick = {}, isEnabled = false)
+ }
+ }
+}
+
+@Composable
+fun RedeemVoucherButton(
+ modifier: Modifier = Modifier,
+ background: Color = MaterialTheme.colorScheme.background,
+ onClick: () -> Unit,
+ isEnabled: Boolean
+) {
+ ActionButton(
+ text = stringResource(id = R.string.redeem_voucher),
+ onClick = onClick,
+ modifier = modifier,
+ colors =
+ ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ containerColor = MaterialTheme.colorScheme.surface,
+ disabledContentColor =
+ MaterialTheme.colorScheme.onPrimary
+ .copy(alpha = AlphaInactive)
+ .compositeOver(background),
+ disabledContainerColor =
+ MaterialTheme.colorScheme.surface
+ .copy(alpha = AlphaDisabled)
+ .compositeOver(background)
+ ),
+ isEnabled = isEnabled
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt
new file mode 100644
index 0000000000..bc82bca29c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt
@@ -0,0 +1,59 @@
+package net.mullvad.mullvadvpn.compose.button
+
+import androidx.compose.foundation.background
+import androidx.compose.material3.ButtonDefaults
+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.graphics.compositeOver
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.component.SpacedColumn
+import net.mullvad.mullvadvpn.lib.theme.AlphaDisabled
+import net.mullvad.mullvadvpn.lib.theme.AlphaInactive
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+
+@Preview
+@Composable
+private fun PreviewSitePaymentButton() {
+ AppTheme {
+ SpacedColumn(
+ spacing = Dimens.cellVerticalSpacing,
+ modifier = Modifier.background(color = MaterialTheme.colorScheme.background)
+ ) {
+ SitePaymentButton(onClick = {}, isEnabled = true)
+ SitePaymentButton(onClick = {}, isEnabled = false)
+ }
+ }
+}
+
+@Composable
+fun SitePaymentButton(
+ onClick: () -> Unit,
+ isEnabled: Boolean,
+ modifier: Modifier = Modifier,
+ background: Color = MaterialTheme.colorScheme.background,
+) {
+ ExternalActionButton(
+ onClick = onClick,
+ modifier = modifier,
+ colors =
+ ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ containerColor = MaterialTheme.colorScheme.surface,
+ disabledContentColor =
+ MaterialTheme.colorScheme.onPrimary
+ .copy(alpha = AlphaInactive)
+ .compositeOver(background),
+ disabledContainerColor =
+ MaterialTheme.colorScheme.surface
+ .copy(alpha = AlphaDisabled)
+ .compositeOver(background)
+ ),
+ isEnabled = isEnabled,
+ text = stringResource(id = R.string.buy_credit)
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt
new file mode 100644
index 0000000000..e85939c51c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt
@@ -0,0 +1,12 @@
+package net.mullvad.mullvadvpn.compose.extensions
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.UriHandler
+import androidx.compose.ui.res.stringResource
+import net.mullvad.mullvadvpn.R
+
+@Composable
+fun UriHandler.createOpenAccountPageHook(): (String) -> Unit {
+ val accountUrl = stringResource(id = R.string.account_url)
+ return { token -> this.openUri("$accountUrl?token=$token") }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt
new file mode 100644
index 0000000000..a9ab126dae
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt
@@ -0,0 +1,233 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+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.LocalUriHandler
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+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.button.RedeemVoucherButton
+import net.mullvad.mullvadvpn.compose.button.SitePaymentButton
+import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar
+import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
+import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook
+import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState
+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.model.TunnelState
+import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel
+import net.mullvad.talpid.tunnel.ActionAfterDisconnect
+import net.mullvad.talpid.tunnel.ErrorState
+import net.mullvad.talpid.tunnel.ErrorStateCause
+
+@Preview
+@Composable
+private fun PreviewOutOfTimeScreenDisconnected() {
+ AppTheme {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState = OutOfTimeUiState(tunnelState = TunnelState.Disconnected),
+ viewActions = MutableSharedFlow<OutOfTimeViewModel.ViewAction>().asSharedFlow()
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun PreviewOutOfTimeScreenConnecting() {
+ AppTheme {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState = OutOfTimeUiState(tunnelState = TunnelState.Connecting(null, null)),
+ viewActions = MutableSharedFlow<OutOfTimeViewModel.ViewAction>().asSharedFlow()
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun PreviewOutOfTimeScreenError() {
+ AppTheme {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState =
+ OutOfTimeUiState(
+ tunnelState =
+ TunnelState.Error(
+ ErrorState(cause = ErrorStateCause.IsOffline, isBlocking = true)
+ )
+ ),
+ viewActions = MutableSharedFlow<OutOfTimeViewModel.ViewAction>().asSharedFlow()
+ )
+ }
+}
+
+@Composable
+fun OutOfTimeScreen(
+ showSitePayment: Boolean,
+ uiState: OutOfTimeUiState,
+ viewActions: SharedFlow<OutOfTimeViewModel.ViewAction>,
+ onDisconnectClick: () -> Unit = {},
+ onSitePaymentClick: () -> Unit = {},
+ onRedeemVoucherClick: () -> Unit = {},
+ openConnectScreen: () -> Unit = {},
+ onSettingsClick: () -> Unit = {},
+ onAccountClick: () -> Unit = {}
+) {
+ val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook()
+ LaunchedEffect(key1 = Unit) {
+ viewActions.collect { viewAction ->
+ when (viewAction) {
+ is OutOfTimeViewModel.ViewAction.OpenAccountView ->
+ openAccountPage(viewAction.token)
+ OutOfTimeViewModel.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.background)
+ .padding(it)
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.icon_fail),
+ contentDescription = null,
+ modifier =
+ Modifier.align(Alignment.CenterHorizontally)
+ .padding(vertical = Dimens.screenVerticalMargin)
+ )
+ Text(
+ text = stringResource(id = R.string.out_of_time),
+ style = MaterialTheme.typography.headlineLarge,
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.padding(horizontal = Dimens.sideMargin)
+ )
+ Text(
+ text =
+ buildString {
+ append(stringResource(R.string.account_credit_has_expired))
+ if (showSitePayment) {
+ append(" ")
+ append(stringResource(R.string.add_time_to_account))
+ }
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier =
+ Modifier.padding(
+ top = Dimens.mediumPadding,
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin
+ )
+ )
+ Spacer(modifier = Modifier.weight(1f).defaultMinSize(minHeight = Dimens.verticalSpace))
+ // Button area
+ if (uiState.tunnelState.showDisconnectButton()) {
+ ActionButton(
+ onClick = onDisconnectClick,
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError
+ ),
+ text = stringResource(id = R.string.disconnect),
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ bottom = Dimens.buttonSeparation
+ )
+ )
+ }
+ if (showSitePayment) {
+ SitePaymentButton(
+ onClick = onSitePaymentClick,
+ isEnabled = uiState.tunnelState.enableSitePaymentButton(),
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ bottom = Dimens.buttonSeparation
+ )
+ )
+ }
+ RedeemVoucherButton(
+ onClick = onRedeemVoucherClick,
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ bottom = Dimens.screenVerticalMargin
+ ),
+ isEnabled = uiState.tunnelState.enableRedeemButton()
+ )
+ }
+ }
+}
+
+private fun TunnelState.showDisconnectButton(): Boolean =
+ when (this) {
+ is TunnelState.Disconnected -> false
+ is TunnelState.Connecting,
+ is TunnelState.Connected -> true
+ is TunnelState.Disconnecting -> {
+ this.actionAfterDisconnect != ActionAfterDisconnect.Nothing
+ }
+ is TunnelState.Error -> this.errorState.isBlocking
+ }
+
+private fun TunnelState.enableSitePaymentButton(): Boolean = this is TunnelState.Disconnected
+
+private fun TunnelState.enableRedeemButton(): Boolean =
+ !(this is TunnelState.Error && this.errorState.cause is ErrorStateCause.IsOffline)
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
index 038eb60663..b7dc83ac48 100644
--- 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
@@ -1,10 +1,8 @@
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
@@ -13,7 +11,6 @@ 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
@@ -21,17 +18,14 @@ 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.button.RedeemVoucherButton
+import net.mullvad.mullvadvpn.compose.button.SitePaymentButton
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
import net.mullvad.mullvadvpn.compose.state.WelcomeUiState
@@ -192,51 +186,25 @@ fun WelcomeScreen(
) {
Spacer(modifier = Modifier.padding(top = Dimens.screenVerticalMargin))
if (showSitePayment) {
- ActionButton(
+ SitePaymentButton(
onClick = onSitePaymentClick,
+ isEnabled = true,
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),
+ RedeemVoucherButton(
onClick = onRedeemVoucherClick,
+ isEnabled = true,
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/OutOfTimeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt
new file mode 100644
index 0000000000..cc19ac7ca8
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt
@@ -0,0 +1,5 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.model.TunnelState
+
+data class OutOfTimeUiState(val tunnelState: TunnelState = TunnelState.Disconnected)
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 3c4315ecb9..987a55b45f 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
@@ -24,6 +24,7 @@ import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel
import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel
import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel
import net.mullvad.mullvadvpn.viewmodel.LoginViewModel
+import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel
import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel
import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel
import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel
@@ -86,6 +87,7 @@ val uiModule = module {
viewModel { SettingsViewModel(get(), get()) }
viewModel { VpnSettingsViewModel(get(), get(), get(), get()) }
viewModel { WelcomeViewModel(get(), get(), get()) }
+ viewModel { OutOfTimeViewModel(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 c5a5ee7634..8d3bf00010 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
@@ -4,197 +4,49 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import android.widget.TextView
-import androidx.core.view.isVisible
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import kotlin.properties.Delegates.observable
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.emptyFlow
-import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.launch
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.platform.ComposeView
import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL
+import net.mullvad.mullvadvpn.compose.screen.OutOfTimeScreen
import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD
-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.ui.serviceconnection.ServiceConnectionManager
-import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
-import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache
-import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy
-import net.mullvad.mullvadvpn.ui.widget.Button
-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.callbackFlowFromNotifier
-import net.mullvad.talpid.tunnel.ActionAfterDisconnect
-import net.mullvad.talpid.tunnel.ErrorStateCause
-import org.joda.time.DateTime
-import org.koin.android.ext.android.inject
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.ui.MainActivity
+import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel
+import org.koin.androidx.viewmodel.ext.android.viewModel
class OutOfTimeFragment : BaseFragment() {
- // Injected dependencies
- private val accountRepository: AccountRepository by inject()
- private val serviceConnectionManager: ServiceConnectionManager by inject()
-
- private lateinit var headerBar: HeaderBar
-
- private lateinit var sitePaymentButton: SitePaymentButton
- private lateinit var disconnectButton: Button
- private lateinit var redeemButton: RedeemVoucherButton
-
- private var tunnelState by
- observable<TunnelState>(TunnelState.Disconnected) { _, _, state ->
- updateDisconnectButton()
- updateBuyButtons()
- headerBar.tunnelState = state
- }
-
- @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<OutOfTimeViewModel>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
- val view = inflater.inflate(R.layout.out_of_time, container, false)
-
- headerBar =
- view.findViewById<HeaderBar>(R.id.header_bar).apply {
- tunnelState = this@OutOfTimeFragment.tunnelState
- }
-
- view.findViewById<TextView>(R.id.account_credit_has_expired).text = buildString {
- append(requireActivity().getString(R.string.account_credit_has_expired))
- if (IS_PLAY_BUILD.not()) {
- append(" ")
- append(requireActivity().getString(R.string.add_time_to_account))
- }
- }
-
- disconnectButton =
- view.findViewById<Button>(R.id.disconnect).apply {
- setOnClickAction("disconnect", jobTracker) {
- serviceConnectionManager.connectionProxy()?.disconnect()
- }
- }
-
- sitePaymentButton =
- view.findViewById<SitePaymentButton>(R.id.site_payment).apply {
- newAccount = false
-
- setOnClickAction("openAccountPageInBrowser", jobTracker) {
- isEnabled = false
- serviceConnectionManager.authTokenCache()?.fetchAuthToken()?.let { token ->
- context.openAccountPageInBrowser(token)
- }
- isEnabled = true
- }
-
- isEnabled = true
- }
-
- sitePaymentButton.isVisible = IS_PLAY_BUILD.not()
-
- redeemButton =
- 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) {
- launchProceedToConnectViewIfExpiryExtended()
- launchExpiryPolling()
- launchTunnelStateSubscription()
- }
- }
-
- private fun CoroutineScope.launchProceedToConnectViewIfExpiryExtended() = launch {
- accountRepository.accountExpiryState
- .map { state -> state.date() }
- .collect { expiryDate -> checkExpiry(expiryDate) }
- }
-
- private fun CoroutineScope.launchExpiryPolling() = launch {
- while (true) {
- accountRepository.fetchAccountExpiry()
- delay(ACCOUNT_EXPIRY_POLL_INTERVAL)
- }
- }
-
- private fun CoroutineScope.launchTunnelStateSubscription() = launch {
- serviceConnectionManager.connectionState
- .flatMapLatest { state ->
- if (state is ServiceConnectionState.ConnectedReady) {
- callbackFlowFromNotifier(state.container.connectionProxy.onStateChange)
- } else {
- emptyFlow()
- }
- }
- .collect { newState -> tunnelState = newState }
- }
-
- private fun updateDisconnectButton() {
- val state = tunnelState
-
- val showButton =
- when (state) {
- is TunnelState.Disconnected -> false
- is TunnelState.Connecting,
- is TunnelState.Connected -> true
- is TunnelState.Disconnecting -> {
- state.actionAfterDisconnect != ActionAfterDisconnect.Nothing
+ return inflater.inflate(R.layout.fragment_compose, container, false).apply {
+ findViewById<ComposeView>(R.id.compose_view).setContent {
+ AppTheme {
+ val state = vm.uiState.collectAsState().value
+ OutOfTimeScreen(
+ showSitePayment = IS_PLAY_BUILD.not(),
+ uiState = state,
+ viewActions = vm.viewActions,
+ onSitePaymentClick = vm::onSitePaymentClick,
+ onRedeemVoucherClick = ::openRedeemVoucherFragment,
+ onSettingsClick = ::openSettingsView,
+ onAccountClick = ::openAccountView,
+ openConnectScreen = ::advanceToConnectScreen,
+ onDisconnectClick = vm::onDisconnectClick
+ )
}
- is TunnelState.Error -> state.errorState.isBlocking
- }
-
- disconnectButton.apply {
- if (showButton) {
- isEnabled = true
- visibility = View.VISIBLE
- } else {
- isEnabled = false
- visibility = View.GONE
}
}
}
- private fun updateBuyButtons() {
- val currentState = tunnelState
- val hasConnectivity = currentState is TunnelState.Disconnected
- sitePaymentButton.isEnabled = hasConnectivity
-
- val isOffline =
- currentState is TunnelState.Error &&
- currentState.errorState.cause is ErrorStateCause.IsOffline
- redeemButton.isEnabled = !isOffline
- }
-
- private fun checkExpiry(maybeExpiry: DateTime?) {
- maybeExpiry?.let { expiry ->
- if (expiry.isAfterNow) {
- jobTracker.newUiJob("advanceToConnectScreen") { advanceToConnectScreen() }
- }
- }
+ private fun openRedeemVoucherFragment() {
+ val transaction = parentFragmentManager.beginTransaction()
+ transaction.addToBackStack(null)
+ RedeemVoucherDialogFragment().show(transaction, null)
}
private fun advanceToConnectScreen() {
@@ -203,4 +55,12 @@ class OutOfTimeFragment : BaseFragment() {
commitAllowingStateLoss()
}
}
+
+ private fun openSettingsView() {
+ (context as? MainActivity)?.openSettings()
+ }
+
+ private fun openAccountView() {
+ (context as? MainActivity)?.openAccount()
+ }
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/RedeemVoucherButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/RedeemVoucherButton.kt
deleted file mode 100644
index b6d5ddb88d..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/RedeemVoucherButton.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-package net.mullvad.mullvadvpn.ui.widget
-
-import android.content.Context
-import android.util.AttributeSet
-import androidx.fragment.app.FragmentManager
-import net.mullvad.mullvadvpn.lib.common.util.JobTracker
-import net.mullvad.mullvadvpn.ui.fragment.RedeemVoucherDialogFragment
-
-class RedeemVoucherButton : Button {
- constructor(context: Context) : super(context)
-
- constructor(context: Context, attributes: AttributeSet) : super(context, attributes)
-
- constructor(
- context: Context,
- attributes: AttributeSet,
- defaultStyleAttribute: Int
- ) : super(context, attributes, defaultStyleAttribute)
-
- fun prepare(
- fragmentManager: FragmentManager?,
- jobTracker: JobTracker,
- jobName: String = "openRedeemVoucherDialog"
- ) {
- setOnClickAction(jobName, jobTracker) {
- fragmentManager?.beginTransaction()?.let { transaction ->
- transaction.addToBackStack(null)
-
- RedeemVoucherDialogFragment().show(transaction, null)
- }
- }
- }
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SitePaymentButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SitePaymentButton.kt
deleted file mode 100644
index 9fbe71337e..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SitePaymentButton.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package net.mullvad.mullvadvpn.ui.widget
-
-import android.content.Context
-import android.util.AttributeSet
-import kotlin.properties.Delegates.observable
-import net.mullvad.mullvadvpn.R
-
-class SitePaymentButton : UrlButton {
- constructor(context: Context) : super(context)
-
- constructor(context: Context, attributes: AttributeSet) : super(context, attributes)
-
- constructor(
- context: Context,
- attributes: AttributeSet,
- defaultStyleAttribute: Int
- ) : super(context, attributes, defaultStyleAttribute)
-
- var newAccount by
- observable(false) { _, _, isNewAccount ->
- if (isNewAccount) {
- label = context.getString(R.string.buy_credit)
- } else {
- label = context.getString(R.string.buy_more_credit)
- }
- }
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt
deleted file mode 100644
index f6090bdafa..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package net.mullvad.mullvadvpn.ui.widget
-
-import android.content.Context
-import android.util.AttributeSet
-import androidx.appcompat.content.res.AppCompatResources
-import net.mullvad.mullvadvpn.R
-
-open class UrlButton : Button {
- constructor(context: Context) : super(context)
-
- constructor(context: Context, attributes: AttributeSet) : super(context, attributes)
-
- constructor(
- context: Context,
- attributes: AttributeSet,
- defaultStyleAttribute: Int
- ) : super(context, attributes, defaultStyleAttribute)
-
- init {
- super.setEnabled(false)
- super.detailImage = AppCompatResources.getDrawable(context, R.drawable.icon_extlink)
- super.showSpinner = true
- }
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt
new file mode 100644
index 0000000000..00f3850777
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt
@@ -0,0 +1,97 @@
+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.emptyFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState
+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.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.ui.serviceconnection.connectionProxy
+import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier
+import org.joda.time.DateTime
+
+@OptIn(FlowPreview::class)
+class OutOfTimeViewModel(
+ private val accountRepository: AccountRepository,
+ 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 ->
+ serviceConnection.connectionProxy.tunnelStateFlow()
+ }
+ .map { tunnelState -> OutOfTimeUiState(tunnelState = tunnelState) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), OutOfTimeUiState())
+
+ 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.tunnelStateFlow(): Flow<TunnelState> =
+ callbackFlowFromNotifier(this.onStateChange)
+
+ fun onSitePaymentClick() {
+ viewModelScope.launch {
+ _viewActions.tryEmit(
+ ViewAction.OpenAccountView(
+ serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: ""
+ )
+ )
+ }
+ }
+
+ fun onDisconnectClick() {
+ viewModelScope.launch { serviceConnectionManager.connectionProxy()?.disconnect() }
+ }
+
+ sealed interface ViewAction {
+ data class OpenAccountView(val token: String) : ViewAction
+
+ data object OpenConnectScreen : ViewAction
+ }
+}
diff --git a/android/app/src/main/res/layout/out_of_time.xml b/android/app/src/main/res/layout/out_of_time.xml
deleted file mode 100644
index 791b2d8a77..0000000000
--- a/android/app/src/main/res/layout/out_of_time.xml
+++ /dev/null
@@ -1,59 +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">
- <ImageView android:layout_width="60dp"
- android:layout_height="60dp"
- android:layout_gravity="center"
- android:layout_marginTop="@dimen/screen_vertical_margin"
- android:layout_marginBottom="18dp"
- android:src="@drawable/icon_fail" />
- <TextView android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="@dimen/side_margin"
- android:textColor="@color/white"
- android:textSize="@dimen/text_huge"
- android:textStyle="bold"
- android:text="@string/out_of_time" />
- <TextView android:id="@+id/account_credit_has_expired"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="@dimen/side_margin"
- android:layout_marginTop="8dp"
- 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_weight="0"
- android:orientation="vertical"
- android:paddingTop="@dimen/button_separation"
- android:paddingBottom="@dimen/screen_vertical_margin"
- android:background="@color/darkBlue">
- <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/disconnect"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="@dimen/side_margin"
- android:layout_marginBottom="@dimen/button_separation"
- android:visibility="gone"
- mullvad:buttonColor="red"
- mullvad:text="@string/disconnect" />
- <include layout="@layout/payment_buttons" />
- </LinearLayout>
- </LinearLayout>
- </ScrollView>
-</RelativeLayout>
diff --git a/android/app/src/main/res/layout/payment_buttons.xml b/android/app/src/main/res/layout/payment_buttons.xml
deleted file mode 100644
index c617bb1571..0000000000
--- a/android/app/src/main/res/layout/payment_buttons.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<merge xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:mullvad="http://schemas.android.com/apk/res-auto">
- <net.mullvad.mullvadvpn.ui.widget.SitePaymentButton android:id="@+id/site_payment"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="@dimen/side_margin"
- mullvad:buttonColor="green" />
- <net.mullvad.mullvadvpn.ui.widget.RedeemVoucherButton android:id="@+id/redeem_voucher"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/button_separation"
- android:layout_marginHorizontal="@dimen/side_margin"
- mullvad:buttonColor="green"
- mullvad:text="@string/redeem_voucher" />
-</merge>
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt
new file mode 100644
index 0000000000..b12c0382a5
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt
@@ -0,0 +1,149 @@
+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 io.mockk.verify
+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.compose.state.OutOfTimeUiState
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.model.AccountExpiry
+import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.repository.AccountRepository
+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.mullvadvpn.ui.serviceconnection.connectionProxy
+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 OutOfTimeViewModelTest {
+ @get:Rule val testCoroutineRule = TestCoroutineRule()
+
+ private val serviceConnectionState =
+ MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected)
+ private val accountExpiryState = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing)
+
+ // Service connections
+ private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk()
+ private val mockConnectionProxy: ConnectionProxy = mockk()
+
+ // Event notifiers
+ private val eventNotifierTunnelRealState = EventNotifier<TunnelState>(TunnelState.Disconnected)
+
+ private val mockAccountRepository: AccountRepository = mockk()
+ private val mockServiceConnectionManager: ServiceConnectionManager = mockk()
+
+ private lateinit var viewModel: OutOfTimeViewModel
+
+ @Before
+ fun setUp() {
+ mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS)
+
+ every { mockServiceConnectionManager.connectionState } returns serviceConnectionState
+
+ every { mockServiceConnectionContainer.connectionProxy } returns mockConnectionProxy
+
+ every { mockConnectionProxy.onStateChange } returns eventNotifierTunnelRealState
+
+ every { mockAccountRepository.accountExpiryState } returns accountExpiryState
+
+ viewModel =
+ OutOfTimeViewModel(
+ accountRepository = mockAccountRepository,
+ 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<OutOfTimeViewModel.ViewAction.OpenAccountView>(action)
+ assertEquals(mockToken, action.token)
+ }
+ }
+
+ @Test
+ fun testUpdateTunnelState() =
+ runTest(testCoroutineRule.testDispatcher) {
+ // Arrange
+ val tunnelRealStateTestItem = TunnelState.Connected(mockk(), mockk())
+
+ // Act, Assert
+ viewModel.uiState.test {
+ assertEquals(OutOfTimeUiState(), awaitItem())
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ eventNotifierTunnelRealState.notify(tunnelRealStateTestItem)
+ val result = awaitItem()
+ assertEquals(tunnelRealStateTestItem, result.tunnelState)
+ }
+ }
+
+ @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<OutOfTimeViewModel.ViewAction.OpenConnectScreen>(action)
+ }
+ }
+
+ @Test
+ fun testOnDisconnectClick() =
+ runTest(testCoroutineRule.testDispatcher) {
+ // Arrange
+ val mockProxy: ConnectionProxy = mockk(relaxed = true)
+ every { mockServiceConnectionManager.connectionProxy() } returns mockProxy
+
+ // Act
+ viewModel.onDisconnectClick()
+
+ // Assert
+ verify { mockProxy.disconnect() }
+ }
+
+ 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/Theme.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
index ab3a2f61c0..7e48417168 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
@@ -28,6 +28,7 @@ private val MullvadTypography =
fontSize = TypeScale.TextBig,
fontWeight = FontWeight.Bold
),
+ bodyMedium = TextStyle(fontSize = TypeScale.TextMediumPlus, fontWeight = FontWeight.Bold),
bodySmall = TextStyle(color = MullvadWhite, fontSize = TypeScale.TextSmall),
titleSmall =
TextStyle(