From db61ff7970d01daa674350b0e87a28f3c10b1580 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Wed, 27 Sep 2023 15:19:58 +0200 Subject: [PATCH] Add billing payment to out of time screen and view model --- .../compose/screen/OutOfTimeScreen.kt | 88 ++++++++++++++++++- .../compose/state/OutOfTimeUiState.kt | 5 +- .../net/mullvad/mullvadvpn/di/UiModule.kt | 2 +- .../ui/fragment/OutOfTimeFragment.kt | 5 +- .../viewmodel/OutOfTimeViewModel.kt | 77 +++++++++++++--- 5 files changed, 163 insertions(+), 14 deletions(-) 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 index a772a45750fd..ccc36a654c18 100644 --- 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 @@ -7,8 +7,10 @@ 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.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -26,10 +28,15 @@ import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.NegativeButton import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton import net.mullvad.mullvadvpn.compose.button.SitePaymentButton +import net.mullvad.mullvadvpn.compose.button.VariantButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.dialog.PaymentAvailabilityDialog +import net.mullvad.mullvadvpn.compose.dialog.PurchaseResultDialog import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState +import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar @@ -94,7 +101,10 @@ fun OutOfTimeScreen( onRedeemVoucherClick: () -> Unit = {}, openConnectScreen: () -> Unit = {}, onSettingsClick: () -> Unit = {}, - onAccountClick: () -> Unit = {} + onAccountClick: () -> Unit = {}, + onPurchaseBillingProductClick: (String) -> Unit = {}, + onTryFetchProductsAgain: () -> Unit = {}, + onTryVerificationAgain: () -> Unit = {} ) { val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook() LaunchedEffect(key1 = Unit) { @@ -106,6 +116,19 @@ fun OutOfTimeScreen( } } } + + uiState.purchaseResult?.let { + PurchaseResultDialog( + purchaseResult = uiState.purchaseResult, + onTryAgain = onTryVerificationAgain + ) + } + + PaymentAvailabilityDialog( + paymentAvailability = uiState.billingPaymentState, + onTryAgain = onTryFetchProductsAgain + ) + val scrollState = rememberScrollState() ScaffoldWithTopBarAndDeviceName( topBarColor = @@ -189,6 +212,69 @@ fun OutOfTimeScreen( ) ) } + when (uiState.billingPaymentState) { + is PaymentState.Error -> { + // 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 -> + if (product.status != PaymentStatus.AVAILABLE) { + Text( + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground, + text = + stringResource( + id = + if ( + product.status == + PaymentStatus.VERIFICATION_IN_PROGRESS + ) { + R.string.payment_status_verification_in_progress + } else { + R.string.payment_status_pending + } + ), + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.smallPadding + ) + ) + } + VariantButton( + 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 + ), + isEnabled = product.status == PaymentStatus.AVAILABLE + ) + } + } + } if (showSitePayment) { SitePaymentButton( onClick = onSitePaymentClick, 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 index f7794e5a5599..a918f0d861b3 100644 --- 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 @@ -1,8 +1,11 @@ package net.mullvad.mullvadvpn.compose.state +import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult import net.mullvad.mullvadvpn.model.TunnelState data class OutOfTimeUiState( val tunnelState: TunnelState = TunnelState.Disconnected, - val deviceName: String + val deviceName: String = "", + val billingPaymentState: PaymentState = PaymentState.Loading, + val purchaseResult: PurchaseResult? = null ) 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 d771cf355c2b..b82d564c07f8 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 @@ -103,7 +103,7 @@ val uiModule = module { viewModel { WelcomeViewModel(get(), get(), get(), get()) } viewModel { ReportProblemViewModel(get()) } viewModel { ViewLogsViewModel(get()) } - viewModel { OutOfTimeViewModel(get(), get(), get()) } + viewModel { OutOfTimeViewModel(get(), get(), get(), get()) } } const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt index 700ffba14576..cafc51fab0ec 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 @@ -36,7 +36,10 @@ class OutOfTimeFragment : BaseFragment() { onSettingsClick = ::openSettingsView, onAccountClick = ::openAccountView, openConnectScreen = ::advanceToConnectScreen, - onDisconnectClick = vm::onDisconnectClick + onDisconnectClick = vm::onDisconnectClick, + onPurchaseBillingProductClick = vm::startBillingPayment, + onTryFetchProductsAgain = vm::fetchPaymentAvailability, + onTryVerificationAgain = vm::verifyPurchases ) } } 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 index b1df2d222586..b35722e432d1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt @@ -2,20 +2,28 @@ 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.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine 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.PaymentProvider import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState +import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL +import net.mullvad.mullvadvpn.lib.payment.extensions.toPurchaseResult +import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability +import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult +import net.mullvad.mullvadvpn.lib.payment.model.VerificationResult import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository @@ -27,14 +35,17 @@ 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 deviceRepository: DeviceRepository, + paymentProvider: PaymentProvider, private val pollAccountExpiry: Boolean = true, ) : ViewModel() { + private val paymentRepository = paymentProvider.paymentRepository + private val _paymentAvailability = MutableStateFlow(null) + private val _purchaseResult = MutableStateFlow(null) private val _uiSideEffect = MutableSharedFlow(extraBufferCapacity = 1) val uiSideEffect = _uiSideEffect.asSharedFlow() @@ -48,21 +59,22 @@ class OutOfTimeViewModel( } } .flatMapLatest { serviceConnection -> - kotlinx.coroutines.flow.combine( + combine( serviceConnection.connectionProxy.tunnelStateFlow(), - deviceRepository.deviceState - ) { tunnelState, deviceState -> + deviceRepository.deviceState, + _paymentAvailability, + _purchaseResult + ) { tunnelState, deviceState, paymentAvailability, purchaseResult -> OutOfTimeUiState( tunnelState = tunnelState, deviceName = deviceState.deviceName() ?: "", + billingPaymentState = paymentAvailability?.toPaymentState() + ?: PaymentState.NoPayment, + purchaseResult = purchaseResult ) } } - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(), - OutOfTimeUiState(deviceName = "") - ) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), OutOfTimeUiState()) init { viewModelScope.launch { @@ -82,6 +94,8 @@ class OutOfTimeViewModel( delay(ACCOUNT_EXPIRY_POLL_INTERVAL) } } + verifyPurchases(updatePurchaseResult = false) + fetchPaymentAvailability() } private fun ConnectionProxy.tunnelStateFlow(): Flow = @@ -101,6 +115,49 @@ class OutOfTimeViewModel( viewModelScope.launch { serviceConnectionManager.connectionProxy()?.disconnect() } } + fun startBillingPayment(productId: String) { + viewModelScope.launch { + try { + paymentRepository?.purchaseBillingProduct(productId)?.collect(_purchaseResult) + } finally { + // Update payment status in case the payment is pending or the verification failed + fetchPaymentAvailability() + } + } + } + + fun verifyPurchases(updatePurchaseResult: Boolean = true) { + viewModelScope.launch { + if (updatePurchaseResult) { + paymentRepository + ?.verifyPurchases() + ?.map(VerificationResult::toPurchaseResult) + ?.collect(_purchaseResult) + } else { + paymentRepository?.verifyPurchases() + } + } + } + + fun fetchPaymentAvailability() { + viewModelScope.launch { + _paymentAvailability.emit(PaymentAvailability.Loading) + delay(100L) // So that the ui gets a new state in retries + paymentRepository?.queryPaymentAvailability()?.collect(_paymentAvailability) + ?: run { _paymentAvailability.emit(PaymentAvailability.ProductsUnavailable) } + } + } + + private fun PaymentAvailability.toPaymentState(): PaymentState = + when (this) { + PaymentAvailability.Error.ServiceUnavailable, + PaymentAvailability.Error.BillingUnavailable -> PaymentState.Error.BillingError + is PaymentAvailability.Error.Other -> PaymentState.Error.GenericError + is PaymentAvailability.ProductsAvailable -> PaymentState.PaymentAvailable(products) + PaymentAvailability.ProductsUnavailable -> PaymentState.NoPayment + PaymentAvailability.Loading -> PaymentState.Loading + } + sealed interface UiSideEffect { data class OpenAccountView(val token: String) : UiSideEffect