Skip to content

Commit

Permalink
Replace retry with exponential backoff with arrow schedule
Browse files Browse the repository at this point in the history
Also clean up the code related to play purchase verification
  • Loading branch information
Pururun committed Jun 18, 2024
1 parent a05655b commit b0d5a30
Show file tree
Hide file tree
Showing 15 changed files with 73 additions and 98 deletions.
1 change: 1 addition & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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<PaymentAvailability?>
Expand All @@ -26,7 +30,7 @@ interface PaymentUseCase {

suspend fun resetPurchaseResult()

suspend fun verifyPurchases(onSuccessfulVerification: () -> Unit = {})
suspend fun verifyPurchases(): Either<VerificationError, VerificationResult>
}

class PlayPaymentUseCase(private val paymentRepository: PaymentRepository) : PaymentUseCase {
Expand Down Expand Up @@ -56,24 +60,19 @@ class PlayPaymentUseCase(private val paymentRepository: PaymentRepository) : Pay
_purchaseResult.emit(null)
}

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<VerificationError>(
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
Expand All @@ -99,7 +98,5 @@ class EmptyPaymentUseCase : PaymentUseCase {
// No op
}

override suspend fun verifyPurchases(onSuccessfulVerification: () -> Unit) {
// No op
}
override suspend fun verifyPurchases() = VerificationResult.NothingToVerify.right()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T1, T2, T3, T4, T5, T6, R> combine(
flow: Flow<T1>,
Expand Down Expand Up @@ -90,34 +86,3 @@ fun <T> Deferred<T>.getOrDefault(default: T) =
} catch (e: IllegalStateException) {
default
}

@Suppress("UNCHECKED_CAST")
suspend inline fun <T> Flow<T>.retryWithExponentialBackOff(
maxAttempts: Int,
initialBackOffDelay: Long,
backOffDelayFactor: Long,
crossinline predicate: (T) -> Boolean,
): Flow<T> =
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()
Original file line number Diff line number Diff line change
@@ -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<VerificationError, VerificationResult>.isSuccess() =
getOrNull() == VerificationResult.Success
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -90,8 +91,9 @@ class AccountViewModel(

private fun verifyPurchases() {
viewModelScope.launch {
paymentUseCase.verifyPurchases()
updateAccountExpiry()
if (paymentUseCase.verifyPurchases().isSuccess()) {
updateAccountExpiry()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState
import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL
import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken
import net.mullvad.mullvadvpn.lib.payment.model.VerificationResult
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.OutOfTimeUseCase
import net.mullvad.mullvadvpn.usecase.PaymentUseCase
import net.mullvad.mullvadvpn.util.isSuccess
import net.mullvad.mullvadvpn.util.toPaymentState

class OutOfTimeViewModel(
Expand Down Expand Up @@ -76,8 +78,9 @@ class OutOfTimeViewModel(

private fun verifyPurchases() {
viewModelScope.launch {
paymentUseCase.verifyPurchases()
updateAccountExpiry()
if (paymentUseCase.verifyPurchases().isSuccess()) {
updateAccountExpiry()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -79,8 +80,9 @@ class WelcomeViewModel(

private fun verifyPurchases() {
viewModelScope.launch {
paymentUseCase.verifyPurchases()
updateAccountExpiry()
if (paymentUseCase.verifyPurchases().isSuccess()) {
updateAccountExpiry()
}
}
}

Expand Down
1 change: 1 addition & 0 deletions android/buildSrc/src/main/kotlin/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -129,28 +133,19 @@ class BillingPaymentRepository(
}
}

override fun verifyPurchases(): Flow<VerificationResult> = flow {
emit(VerificationResult.FetchingUnfinishedPurchases)
override suspend fun verifyPurchases(): Either<VerificationError, VerificationResult> = 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()
Expand Down
1 change: 1 addition & 0 deletions android/lib/payment/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ android {
}

dependencies {
implementation(Dependencies.Arrow.core)
implementation(Dependencies.Kotlin.stdlib)
implementation(Dependencies.KotlinX.coroutinesAndroid)
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -14,7 +16,7 @@ interface PaymentRepository {
activityProvider: () -> Activity
): Flow<PurchaseResult>

fun verifyPurchases(): Flow<VerificationResult>
suspend fun verifyPurchases(): Either<VerificationError, VerificationResult>

fun queryPaymentAvailability(): Flow<PaymentAvailability>
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
}

0 comments on commit b0d5a30

Please sign in to comment.