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 038eb60663db..a50b1b7eb57e 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 @@ -10,16 +10,19 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size 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.CircularProgressIndicator 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.graphics.compositeOver import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -34,10 +37,17 @@ 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.dialog.PaymentBillingErrorDialog +import net.mullvad.mullvadvpn.compose.dialog.PaymentCompletedDialog +import net.mullvad.mullvadvpn.compose.dialog.PaymentVerificationErrorDialog +import net.mullvad.mullvadvpn.compose.state.AccountDialogState +import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.WelcomeDialogState import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.lib.common.util.SdkUtils import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser +import net.mullvad.mullvadvpn.lib.payment.PaymentProduct import net.mullvad.mullvadvpn.lib.theme.AlphaTopBar import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -49,28 +59,36 @@ import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel private fun PreviewWelcomeScreen() { AppTheme { WelcomeScreen( - showSitePayment = true, - uiState = WelcomeUiState(accountNumber = "4444555566667777"), + uiState = WelcomeUiState(accountNumber = "4444555566667777", webPaymentAvailable = true, billingPaymentState = PaymentState.PaymentAvailable(products = listOf( + PaymentProduct("product", "$44") + ))), viewActions = MutableSharedFlow().asSharedFlow(), onSitePaymentClick = {}, onRedeemVoucherClick = {}, onSettingsClick = {}, onAccountClick = {}, - openConnectScreen = {} + openConnectScreen = {}, + onPurchaseBillingProductClick = {}, + onDialogClose = {}, + onTryFetchProductsAgain = {}, + onTryVerificationAgain = {} ) } } @Composable fun WelcomeScreen( - showSitePayment: Boolean, uiState: WelcomeUiState, viewActions: SharedFlow, onSitePaymentClick: () -> Unit, onRedeemVoucherClick: () -> Unit, onSettingsClick: () -> Unit, onAccountClick: () -> Unit, - openConnectScreen: () -> Unit + openConnectScreen: () -> Unit, + onPurchaseBillingProductClick: (productId: String) -> Unit, + onDialogClose: () -> Unit, + onTryVerificationAgain: () -> Unit, + onTryFetchProductsAgain: () -> Unit ) { val context = LocalContext.current LaunchedEffect(key1 = Unit) { @@ -82,6 +100,34 @@ fun WelcomeScreen( } } } + + when (uiState.dialogState) { + WelcomeDialogState.NoDialog -> { + // Show nothing + } + WelcomeDialogState.PurchaseComplete -> { + PaymentCompletedDialog(onClose = onDialogClose) + } + WelcomeDialogState.BillingError -> { + PaymentBillingErrorDialog( + onTryAgain = { + onDialogClose() + onTryFetchProductsAgain() + }, + onClose = onDialogClose + ) + } + WelcomeDialogState.VerificationError -> { + PaymentVerificationErrorDialog( + onTryAgain = { + onDialogClose() + onTryVerificationAgain() + }, + onClose = onDialogClose + ) + } + } + val scrollState = rememberScrollState() ScaffoldWithTopBar( topBarColor = @@ -167,7 +213,7 @@ fun WelcomeScreen( text = buildString { append(stringResource(id = R.string.pay_to_start_using)) - if (showSitePayment) { + if (uiState.webPaymentAvailable) { append(" ") append(stringResource(id = R.string.add_time_to_account)) } @@ -191,7 +237,51 @@ fun WelcomeScreen( .background(color = MaterialTheme.colorScheme.background) ) { Spacer(modifier = Modifier.padding(top = Dimens.screenVerticalMargin)) - if (showSitePayment) { + when (uiState.billingPaymentState) { + PaymentState.BillingError, + PaymentState.GenericError -> { + // We show some kind of dialog error at the top + } + PaymentState.Loading -> { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onBackground, + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.screenVerticalMargin + ) + .size( + width = Dimens.progressIndicatorSize, + height = Dimens.progressIndicatorSize + ) + .align(Alignment.CenterHorizontally) + ) + } + PaymentState.NoPayment -> { + // Show nothing + } + is PaymentState.PaymentAvailable -> { + uiState.billingPaymentState.products.forEach { product -> + ActionButton( + text = stringResource(id = R.string.add_30_days_time_x, product.price), + onClick = { onPurchaseBillingProductClick(product.productId) }, + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.screenVerticalMargin + ), + colors = + ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + } + } + if (uiState.webPaymentAvailable) { ActionButton( onClick = onSitePaymentClick, modifier = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeDialogState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeDialogState.kt new file mode 100644 index 000000000000..b0da1d984517 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeDialogState.kt @@ -0,0 +1,11 @@ +package net.mullvad.mullvadvpn.compose.state + +sealed interface WelcomeDialogState { + data object NoDialog: WelcomeDialogState + + data object VerificationError: WelcomeDialogState + + data object BillingError: WelcomeDialogState + + data object PurchaseComplete: WelcomeDialogState +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt index b8a12ce4ae53..d45854481f49 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt @@ -4,5 +4,8 @@ import net.mullvad.mullvadvpn.model.TunnelState data class WelcomeUiState( val tunnelState: TunnelState = TunnelState.Disconnected, - val accountNumber: String? = null + val accountNumber: String? = null, + val webPaymentAvailable: Boolean = false, + val billingPaymentState: PaymentState = PaymentState.Loading, + val dialogState: WelcomeDialogState = WelcomeDialogState.NoDialog ) 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 10d6f2016819..8139b51a7e33 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 @@ -98,7 +98,9 @@ val uiModule = module { viewModel { SelectLocationViewModel(get()) } viewModel { SettingsViewModel(get(), get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } - viewModel { WelcomeViewModel(get(), get(), get()) } + viewModel { (activity: Activity) -> + WelcomeViewModel(get(), get(), get(), get { parametersOf(activity) }) + } } const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt index 706bbc48586a..7655b9655e27 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt @@ -14,10 +14,13 @@ import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.ui.MainActivity import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf class WelcomeFragment : BaseFragment() { - private val vm by viewModel() + private val vm by viewModel { + parametersOf(requireActivity()) + } override fun onCreateView( inflater: LayoutInflater, @@ -29,14 +32,17 @@ class WelcomeFragment : BaseFragment() { AppTheme { val state = vm.uiState.collectAsState().value WelcomeScreen( - showSitePayment = BuildTypes.RELEASE != BuildConfig.BUILD_TYPE, uiState = state, viewActions = vm.viewActions, onSitePaymentClick = vm::onSitePaymentClick, onRedeemVoucherClick = ::openRedeemVoucherFragment, onSettingsClick = ::openSettingsView, onAccountClick = ::openAccountView, - openConnectScreen = ::advanceToConnectScreen + openConnectScreen = ::advanceToConnectScreen, + onPurchaseBillingProductClick = vm::startBillingPayment, + onDialogClose = vm::closeDialog, + onTryVerificationAgain = vm::verifyPurchases, + onTryFetchProductsAgain = vm::fetchPaymentAvailability ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt index eaba6ad78417..894fc732406b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collectLatest @@ -16,8 +17,14 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.WelcomeDialogState import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL +import net.mullvad.mullvadvpn.lib.payment.BillingPaymentAvailability +import net.mullvad.mullvadvpn.lib.payment.PaymentAvailability +import net.mullvad.mullvadvpn.lib.payment.PaymentRepository +import net.mullvad.mullvadvpn.lib.payment.PurchaseResult import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository @@ -35,9 +42,12 @@ class WelcomeViewModel( private val accountRepository: AccountRepository, private val deviceRepository: DeviceRepository, private val serviceConnectionManager: ServiceConnectionManager, + private val paymentRepository: PaymentRepository, private val pollAccountExpiry: Boolean = true ) : ViewModel() { + private val _dialogState = MutableStateFlow(WelcomeDialogState.NoDialog) + private val _paymentAvailability = MutableStateFlow(null) private val _viewActions = MutableSharedFlow(extraBufferCapacity = 1) val viewActions = _viewActions.asSharedFlow() @@ -55,9 +65,19 @@ class WelcomeViewModel( serviceConnection.connectionProxy.tunnelUiStateFlow(), deviceRepository.deviceState.debounce { it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS) - } - ) { tunnelState, deviceState -> - WelcomeUiState(tunnelState = tunnelState, accountNumber = deviceState.token()) + }, + _paymentAvailability, + _dialogState + ) { tunnelState, deviceState, paymentAvailability, dialogState -> + WelcomeUiState( + tunnelState = tunnelState, + accountNumber = deviceState.token(), + webPaymentAvailable = paymentAvailability?.webPaymentAvailable ?: false, + billingPaymentState = + paymentAvailability?.billingPaymentAvailability?.toPaymentState() + ?: PaymentState.Loading, + dialogState = dialogState + ) } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), WelcomeUiState()) @@ -80,6 +100,28 @@ class WelcomeViewModel( delay(ACCOUNT_EXPIRY_POLL_INTERVAL) } } + viewModelScope.launch { + paymentRepository.purchaseResult.collectLatest { result -> + when (result) { + PurchaseResult.PurchaseCancelled -> { + // Do nothing + } + PurchaseResult.PurchaseCompleted -> { + // Show completed dialog + _dialogState.tryEmit(WelcomeDialogState.PurchaseComplete) + } + PurchaseResult.PurchaseError -> { + // Do nothing, errors that we get from here should be shown by google + } + PurchaseResult.VerificationError -> { + // Show verification error + _dialogState.tryEmit(WelcomeDialogState.VerificationError) + } + } + } + } + verifyPurchases() + fetchPaymentAvailability() } private fun ConnectionProxy.tunnelUiStateFlow(): Flow = @@ -95,6 +137,43 @@ class WelcomeViewModel( } } + fun startBillingPayment(productId: String) { + viewModelScope.launch { paymentRepository.purchaseBillingProduct(productId) } + } + + fun closeDialog() { + viewModelScope.launch { _dialogState.tryEmit(WelcomeDialogState.NoDialog) } + } + + fun verifyPurchases() { + viewModelScope.launch { paymentRepository.verifyPurchases() } + } + + fun fetchPaymentAvailability() { + viewModelScope.launch { + val result = paymentRepository.queryAvailablePaymentTypes() + _paymentAvailability.tryEmit(result) + if ( + result.billingPaymentAvailability is + BillingPaymentAvailability.Error.BillingUnavailable || + result.billingPaymentAvailability is + BillingPaymentAvailability.Error.ServiceUnavailable + ) { + _dialogState.tryEmit(WelcomeDialogState.BillingError) + } + } + } + + private fun BillingPaymentAvailability.toPaymentState(): PaymentState = + when (this) { + BillingPaymentAvailability.Error.ServiceUnavailable, + BillingPaymentAvailability.Error.BillingUnavailable -> PaymentState.BillingError + is BillingPaymentAvailability.Error.Other -> PaymentState.GenericError + is BillingPaymentAvailability.ProductsAvailable -> + PaymentState.PaymentAvailable(products) + BillingPaymentAvailability.ProductsUnavailable -> PaymentState.NoPayment + } + sealed interface ViewAction { data class OpenAccountView(val token: String) : ViewAction