diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-09-26 11:32:38 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-09-26 11:32:38 +0200 |
| commit | 29bb3f728840f3766728000da5122f07ff3f0fdb (patch) | |
| tree | f4b5f4ac857a7bdaef6f7e8ada9e7fabc98522cc /android | |
| parent | 77b74239c60356a23546a49726a7337d84b69d7b (diff) | |
| parent | f52aa1729fbabe7213c7c2f3c4d9824704a512ca (diff) | |
| download | mullvadvpn-29bb3f728840f3766728000da5122f07ff3f0fdb.tar.xz mullvadvpn-29bb3f728840f3766728000da5122f07ff3f0fdb.zip | |
Merge branch 'migrate-out-of-time-view-to-compose-droid-55'
Diffstat (limited to 'android')
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( |
