diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 798667ff3f32..8e523ecb3991 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -329,6 +329,7 @@ dependencies { implementation(Dependencies.AndroidX.lifecycleViewmodelKtx) implementation(Dependencies.AndroidX.lifecycleRuntimeCompose) implementation(Dependencies.Arrow.core) + implementation(Dependencies.Arrow.resilience) implementation(Dependencies.Compose.constrainLayout) implementation(Dependencies.Compose.foundation) implementation(Dependencies.Compose.material3) 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 index 1cf1c32b2cf5..ba2413d4fc1c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/PaymentConstant.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/PaymentConstant.kt @@ -1,5 +1,7 @@ package net.mullvad.mullvadvpn.constant +import kotlin.time.Duration.Companion.seconds + const val VERIFICATION_MAX_ATTEMPTS = 4 -const val VERIFICATION_INITIAL_BACK_OFF_MILLISECONDS = 3000L -const val VERIFICATION_BACK_OFF_FACTOR = 3L +val VERIFICATION_INITIAL_BACK_OFF_DURATION = 3.seconds +const val VERIFICATION_BACK_OFF_FACTOR = 3.toDouble() 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 fbd662f95ecd..23f6c797b0f3 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 @@ -1,20 +1,24 @@ package net.mullvad.mullvadvpn.usecase import android.app.Activity +import arrow.core.Either +import arrow.core.right +import arrow.resilience.Schedule +import arrow.resilience.retryEither import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.transform 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_INITIAL_BACK_OFF_DURATION 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.VerificationError import net.mullvad.mullvadvpn.lib.payment.model.VerificationResult -import net.mullvad.mullvadvpn.util.retryWithExponentialBackOff interface PaymentUseCase { val paymentAvailability: Flow @@ -26,7 +30,7 @@ interface PaymentUseCase { suspend fun resetPurchaseResult() - suspend fun verifyPurchases(onSuccessfulVerification: () -> Unit = {}) + suspend fun verifyPurchases(): Either } class PlayPaymentUseCase(private val paymentRepository: PaymentRepository) : PaymentUseCase { @@ -60,24 +64,19 @@ class PlayPaymentUseCase(private val paymentRepository: PaymentRepository) : Pay } @Suppress("ensure every public functions method is named 'invoke' with operator modifier") - override suspend fun verifyPurchases(onSuccessfulVerification: () -> Unit) { - paymentRepository - .verifyPurchases() - .retryWithExponentialBackOff( - maxAttempts = VERIFICATION_MAX_ATTEMPTS, - initialBackOffDelay = VERIFICATION_INITIAL_BACK_OFF_MILLISECONDS, - backOffDelayFactor = VERIFICATION_BACK_OFF_FACTOR - ) { - it is VerificationResult.Error - } - .collect { + override suspend fun verifyPurchases() = + Schedule.exponential( + VERIFICATION_INITIAL_BACK_OFF_DURATION, + VERIFICATION_BACK_OFF_FACTOR + ) + .and(Schedule.recurs(VERIFICATION_MAX_ATTEMPTS.toLong())) + .retryEither { paymentRepository.verifyPurchases() } + .onRight { if (it == VerificationResult.Success) { // Update the payment availability after a successful verification. queryPaymentAvailability() - onSuccessfulVerification() } } - } private fun PurchaseResult?.shouldDelayLoading() = this is PurchaseResult.FetchingProducts || this is PurchaseResult.VerificationStarted @@ -107,7 +106,5 @@ class EmptyPaymentUseCase : PaymentUseCase { } @Suppress("ensure every public functions method is named 'invoke' with operator modifier") - override suspend fun verifyPurchases(onSuccessfulVerification: () -> Unit) { - // No op - } + override suspend fun verifyPurchases() = VerificationResult.NothingToVerify.right() } 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 fbe44a5feabe..13561aa7f858 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 @@ -3,11 +3,7 @@ package net.mullvad.mullvadvpn.util import kotlinx.coroutines.Deferred -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.retryWhen inline fun combine( flow: Flow, @@ -90,34 +86,3 @@ fun Deferred.getOrDefault(default: T) = } catch (e: IllegalStateException) { default } - -@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/VerificationResultExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/VerificationResultExtensions.kt new file mode 100644 index 000000000000..d4484d50e600 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/VerificationResultExtensions.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.util + +import arrow.core.Either +import net.mullvad.mullvadvpn.lib.payment.model.VerificationError +import net.mullvad.mullvadvpn.lib.payment.model.VerificationResult + +fun Either.isSuccess() = + getOrNull() == VerificationResult.Success 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 a42003d6e200..7c2635b63a1a 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 @@ -24,6 +24,7 @@ import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.isSuccess import net.mullvad.mullvadvpn.util.toPaymentState import org.joda.time.DateTime @@ -90,8 +91,9 @@ class AccountViewModel( private fun verifyPurchases() { viewModelScope.launch { - paymentUseCase.verifyPurchases() - updateAccountExpiry() + if (paymentUseCase.verifyPurchases().isSuccess()) { + updateAccountExpiry() + } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt index b41a175dad5f..037c3243ab84 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt @@ -32,6 +32,7 @@ import net.mullvad.mullvadvpn.usecase.PaymentUseCase import net.mullvad.mullvadvpn.usecase.SelectedLocationTitleUseCase import net.mullvad.mullvadvpn.util.combine import net.mullvad.mullvadvpn.util.daysFromNow +import net.mullvad.mullvadvpn.util.isSuccess import net.mullvad.mullvadvpn.util.toInAddress import net.mullvad.mullvadvpn.util.toOutAddress @@ -114,8 +115,8 @@ class ConnectViewModel( init { viewModelScope.launch { - paymentUseCase.verifyPurchases { - viewModelScope.launch { accountRepository.getAccountData() } + if (paymentUseCase.verifyPurchases().isSuccess()) { + accountRepository.getAccountData() } } viewModelScope.launch { deviceRepository.updateDevice() } 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 77841814666f..d2797928534a 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 @@ -20,6 +20,7 @@ import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.isSuccess import net.mullvad.mullvadvpn.util.toPaymentState class OutOfTimeViewModel( @@ -76,8 +77,9 @@ class OutOfTimeViewModel( private fun verifyPurchases() { viewModelScope.launch { - paymentUseCase.verifyPurchases() - updateAccountExpiry() + if (paymentUseCase.verifyPurchases().isSuccess()) { + updateAccountExpiry() + } } } 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 4b61468f8ed0..9322ef9ce4ff 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 @@ -21,6 +21,7 @@ import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.isSuccess import net.mullvad.mullvadvpn.util.toPaymentState class WelcomeViewModel( @@ -79,8 +80,9 @@ class WelcomeViewModel( private fun verifyPurchases() { viewModelScope.launch { - paymentUseCase.verifyPurchases() - updateAccountExpiry() + if (paymentUseCase.verifyPurchases().isSuccess()) { + updateAccountExpiry() + } } } diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt index a31859e5e5ef..26b1a03753a2 100644 --- a/android/buildSrc/src/main/kotlin/Dependencies.kt +++ b/android/buildSrc/src/main/kotlin/Dependencies.kt @@ -46,6 +46,7 @@ object Dependencies { const val core = "io.arrow-kt:arrow-core:${Versions.Arrow.base}" const val optics = "io.arrow-kt:arrow-optics:${Versions.Arrow.base}" const val opticsKsp = "io.arrow-kt:arrow-optics-ksp-plugin:${Versions.Arrow.base}" + const val resilience = "io.arrow-kt:arrow-resilience:${Versions.Arrow.base}" } object Compose { diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index fb241f1a3e1d..cf167c417cb5 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -3266,6 +3266,22 @@ + + + + + + + + + + + + + + + + diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt index 8b3ad66171f9..b526a10032cb 100644 --- a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt @@ -1,6 +1,9 @@ package net.mullvad.mullvadvpn.lib.billing import android.app.Activity +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure import com.android.billingclient.api.BillingClient.BillingResponseCode import com.android.billingclient.api.Purchase import kotlinx.coroutines.flow.Flow @@ -22,6 +25,7 @@ import net.mullvad.mullvadvpn.lib.payment.ProductIds 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.VerificationError import net.mullvad.mullvadvpn.lib.payment.model.VerificationResult class BillingPaymentRepository( @@ -129,28 +133,19 @@ class BillingPaymentRepository( } } - override fun verifyPurchases(): Flow = flow { - emit(VerificationResult.FetchingUnfinishedPurchases) + override suspend fun verifyPurchases(): Either = either { val purchasesResult = billingRepository.queryPurchases() - when (purchasesResult.responseCode()) { - BillingResponseCode.OK -> { - val purchases = purchasesResult.nonPendingPurchases() - if (purchases.isNotEmpty()) { - emit(VerificationResult.VerificationStarted) - emit( - verifyPurchase(purchases.first()) - .fold( - { VerificationResult.Error.VerificationError(null) }, - { VerificationResult.Success } - ) - ) - } else { - emit(VerificationResult.NothingToVerify) - } - } - else -> - emit(VerificationResult.Error.BillingError(purchasesResult.toBillingException())) + ensure(purchasesResult.responseCode() == BillingResponseCode.OK) { + VerificationError.BillingError(purchasesResult.toBillingException()) + } + val purchases = purchasesResult.nonPendingPurchases() + if (purchases.isEmpty()) { + return@either VerificationResult.NothingToVerify } + verifyPurchase(purchases.first()) + .mapLeft { VerificationError.PlayVerificationError } + .map { VerificationResult.Success } + .bind() } private suspend fun initialisePurchase() = playPurchaseRepository.initializePlayPurchase() diff --git a/android/lib/payment/build.gradle.kts b/android/lib/payment/build.gradle.kts index 23f945b4f9f6..892ce21c75df 100644 --- a/android/lib/payment/build.gradle.kts +++ b/android/lib/payment/build.gradle.kts @@ -39,6 +39,7 @@ android { } dependencies { + implementation(Dependencies.Arrow.core) implementation(Dependencies.Kotlin.stdlib) implementation(Dependencies.KotlinX.coroutinesAndroid) } diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/PaymentRepository.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/PaymentRepository.kt index 73fd0c061db0..0f076eab74e1 100644 --- a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/PaymentRepository.kt +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/PaymentRepository.kt @@ -1,10 +1,12 @@ package net.mullvad.mullvadvpn.lib.payment import android.app.Activity +import arrow.core.Either import kotlinx.coroutines.flow.Flow 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.VerificationError import net.mullvad.mullvadvpn.lib.payment.model.VerificationResult interface PaymentRepository { @@ -14,7 +16,7 @@ interface PaymentRepository { activityProvider: () -> Activity ): Flow - fun verifyPurchases(): Flow + suspend fun verifyPurchases(): Either fun queryPaymentAvailability(): Flow } diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/VerificationError.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/VerificationError.kt new file mode 100644 index 000000000000..51cf8d1d28dd --- /dev/null +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/VerificationError.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.lib.payment.model + +sealed interface VerificationError { + data class BillingError(val exception: Throwable) : VerificationError + + data object PlayVerificationError : VerificationError +} diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/VerificationResult.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/VerificationResult.kt index 725ea0af68f2..8cf971194dd9 100644 --- a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/VerificationResult.kt +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/VerificationResult.kt @@ -1,19 +1,7 @@ package net.mullvad.mullvadvpn.lib.payment.model -sealed interface VerificationResult { - data object FetchingUnfinishedPurchases : VerificationResult - - data object VerificationStarted : VerificationResult - - // No verification was needed as there is no purchases to verify +interface VerificationResult { data object NothingToVerify : VerificationResult data object Success : VerificationResult - - // Generic error, add more cases as needed - sealed interface Error : VerificationResult { - data class BillingError(val exception: Throwable?) : Error - - data class VerificationError(val exception: Throwable?) : Error - } }