diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/PaymentConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/PaymentConstant.kt new file mode 100644 index 000000000000..1cf1c32b2cf5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/PaymentConstant.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.constant + +const val VERIFICATION_MAX_ATTEMPTS = 4 +const val VERIFICATION_INITIAL_BACK_OFF_MILLISECONDS = 3000L +const val VERIFICATION_BACK_OFF_FACTOR = 3L diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt index bda53bcaf29e..5a50e8040151 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt @@ -4,11 +4,15 @@ import android.app.Activity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import net.mullvad.mullvadvpn.constant.VERIFICATION_BACK_OFF_FACTOR +import net.mullvad.mullvadvpn.constant.VERIFICATION_INITIAL_BACK_OFF_MILLISECONDS +import net.mullvad.mullvadvpn.constant.VERIFICATION_MAX_ATTEMPTS import net.mullvad.mullvadvpn.lib.payment.PaymentRepository import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult import net.mullvad.mullvadvpn.lib.payment.model.VerificationResult +import net.mullvad.mullvadvpn.util.retryWithExponentialBackOff interface PaymentUseCase { val paymentAvailability: Flow @@ -43,13 +47,22 @@ class PlayPaymentUseCase(private val paymentRepository: PaymentRepository) : Pay } override suspend fun verifyPurchases(onSuccessfulVerification: () -> Unit) { - paymentRepository.verifyPurchases().collect { - if (it == VerificationResult.Success) { - // Update the payment availability after a successful verification. - queryPaymentAvailability() - onSuccessfulVerification() + paymentRepository + .verifyPurchases() + .retryWithExponentialBackOff( + maxAttempts = VERIFICATION_MAX_ATTEMPTS, + initialBackOffDelay = VERIFICATION_INITIAL_BACK_OFF_MILLISECONDS, + backOffDelayFactor = VERIFICATION_BACK_OFF_FACTOR + ) { + it is VerificationResult.Error + } + .collect { + if (it == VerificationResult.Success) { + // Update the payment availability after a successful verification. + queryPaymentAvailability() + onSuccessfulVerification() + } } - } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt index a754b5a6c29e..9be4b13b593b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt @@ -5,9 +5,13 @@ import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.flow.take import kotlinx.coroutines.withTimeoutOrNull import net.mullvad.mullvadvpn.lib.common.util.safeOffer @@ -129,3 +133,34 @@ inline fun combine( suspend inline fun Deferred.awaitWithTimeoutOrNull(timeout: Long) = withTimeoutOrNull(timeout) { await() } + +@Suppress("UNCHECKED_CAST") +suspend inline fun Flow.retryWithExponentialBackOff( + maxAttempts: Int, + initialBackOffDelay: Long, + backOffDelayFactor: Long, + crossinline predicate: (T) -> Boolean, +): Flow = + map { + if (predicate(it)) { + throw ExceptionWrapper(it as Any) + } + it + } + .retryWhen { _, attempt -> + if (attempt >= maxAttempts) { + return@retryWhen false + } + val backOffDelay = initialBackOffDelay * backOffDelayFactor.pow(attempt.toInt()) + delay(backOffDelay) + true + } + .catch { + if (it is ExceptionWrapper) { + this.emit(it.item as T) + } else { + throw it + } + } + +class ExceptionWrapper(val item: Any) : Throwable() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LongExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LongExtensions.kt new file mode 100644 index 000000000000..850776ca7631 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LongExtensions.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.util + +import kotlin.math.pow + +fun Long.pow(exponent: Int): Long = toDouble().pow(exponent).toLong() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt index 5f721674990a..439d0c3c3b1d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt @@ -98,12 +98,13 @@ class AccountViewModel( fun onClosePurchaseResultDialog(success: Boolean) { // We are closing the dialog without any action, this can happen either if an error occurred // during the purchase or the purchase ended successfully. - // In those cases we want to update the both the payment availability and the account - // expiry. + // If the payment was successful we want to update the account expiry. If not successful we + // should check payment availability and verify any purchases to handle potential errors. if (success) { updateAccountExpiry() } else { fetchPaymentAvailability() + verifyPurchases() // Attempt to verify again } viewModelScope.launch { paymentUseCase.resetPurchaseResult() // So that we do not show the dialog again. 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 675ca7ef9439..001469c26b4d 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 @@ -128,13 +128,14 @@ class OutOfTimeViewModel( fun onClosePurchaseResultDialog(success: Boolean) { // We are closing the dialog without any action, this can happen either if an error occurred // during the purchase or the purchase ended successfully. - // In those cases we want to update the both the payment availability and the account - // expiry. + // If the payment was successful we want to update the account expiry. If not successful we + // should check payment availability and verify any purchases to handle potential errors. if (success) { updateAccountExpiry() _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen) } else { fetchPaymentAvailability() + verifyPurchases() // Attempt to verify again } viewModelScope.launch { paymentUseCase.resetPurchaseResult() // So that we do not show the dialog again. 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 566aa319819f..69e9764d4f21 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 @@ -131,13 +131,14 @@ class WelcomeViewModel( fun onClosePurchaseResultDialog(success: Boolean) { // We are closing the dialog without any action, this can happen either if an error occurred // during the purchase or the purchase ended successfully. - // In those cases we want to update the both the payment availability and the account - // expiry. + // If the payment was successful we want to update the account expiry. If not successful we + // should check payment availability and verify any purchases to handle potential errors. if (success) { updateAccountExpiry() _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen) } else { fetchPaymentAvailability() + verifyPurchases() // Attempt to verify again } viewModelScope.launch { paymentUseCase.resetPurchaseResult() // So that we do not show the dialog again.