From 064c12c64803e8dfd6a4de7d08121209d9896761 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 | 32 +++++++- .../compose/state/OutOfTimeUiState.kt | 5 +- .../net/mullvad/mullvadvpn/di/UiModule.kt | 2 +- .../ui/fragment/OutOfTimeFragment.kt | 5 +- .../viewmodel/OutOfTimeViewModel.kt | 77 ++++++++++++++++--- .../viewmodel/OutOfTimeViewModelTest.kt | 3 + 6 files changed, 110 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..230b2b224e8b 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 @@ -24,10 +24,13 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.NegativeButton +import net.mullvad.mullvadvpn.compose.button.PlayPaymentButton import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton import net.mullvad.mullvadvpn.compose.button.SitePaymentButton 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.lib.theme.AppTheme @@ -94,7 +97,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 +112,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 +208,17 @@ fun OutOfTimeScreen( ) ) } + PlayPaymentButton( + billingPaymentState = uiState.billingPaymentState, + onPurchaseBillingProductClick = onPurchaseBillingProductClick, + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.screenVerticalMargin + ) + .align(Alignment.CenterHorizontally) + ) 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 53df05c5f3fe..0c7686f3a474 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 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 index 8c1ec10f5a22..b9e7ff303232 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt @@ -13,6 +13,7 @@ import kotlin.test.assertIs import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.PaymentProvider import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.model.AccountExpiry @@ -53,6 +54,7 @@ class OutOfTimeViewModelTest { private val mockAccountRepository: AccountRepository = mockk() private val mockDeviceRepository: DeviceRepository = mockk() private val mockServiceConnectionManager: ServiceConnectionManager = mockk() + private val mockPaymentProvider: PaymentProvider = mockk(relaxed = true) private lateinit var viewModel: OutOfTimeViewModel @@ -75,6 +77,7 @@ class OutOfTimeViewModelTest { accountRepository = mockAccountRepository, serviceConnectionManager = mockServiceConnectionManager, deviceRepository = mockDeviceRepository, + paymentProvider = mockPaymentProvider, pollAccountExpiry = false ) }