summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-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.kt280
-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
11 files changed, 431 insertions, 333 deletions
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..b36882ef41
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt
@@ -0,0 +1,280 @@
+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.Box
+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.draw.alpha
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.ActionButton
+import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar
+import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
+import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook
+import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState
+import net.mullvad.mullvadvpn.lib.theme.AlphaDisabled
+import net.mullvad.mullvadvpn.lib.theme.AlphaInactive
+import net.mullvad.mullvadvpn.lib.theme.AlphaTopBar
+import net.mullvad.mullvadvpn.lib.theme.AlphaVisible
+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) {
+ ActionButton(
+ onClick = onSitePaymentClick,
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ bottom = Dimens.buttonSeparation
+ ),
+ colors =
+ ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ containerColor = MaterialTheme.colorScheme.surface,
+ disabledContentColor =
+ MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaInactive),
+ disabledContainerColor =
+ MaterialTheme.colorScheme.surface.copy(alpha = AlphaDisabled)
+ ),
+ isEnabled = uiState.tunnelState.enableSitePaymentButton()
+ ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ Text(
+ text = stringResource(id = R.string.buy_more_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)
+ .alpha(
+ if (uiState.tunnelState.enableSitePaymentButton())
+ AlphaVisible
+ else AlphaDisabled
+ )
+ )
+ }
+ }
+ }
+ ActionButton(
+ text = stringResource(id = R.string.redeem_voucher),
+ onClick = onRedeemVoucherClick,
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ bottom = Dimens.screenVerticalMargin
+ ),
+ colors =
+ ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ containerColor = MaterialTheme.colorScheme.surface,
+ disabledContentColor =
+ MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaInactive),
+ disabledContainerColor =
+ MaterialTheme.colorScheme.surface.copy(alpha = AlphaDisabled)
+ ),
+ 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/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>