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 17, 2024
1 parent 55e2277 commit 3373677
Show file tree
Hide file tree
Showing 15 changed files with 75 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,22 @@
package net.mullvad.mullvadvpn.usecase

import android.app.Activity
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 +28,7 @@ interface PaymentUseCase {

suspend fun resetPurchaseResult()

suspend fun verifyPurchases(onSuccessfulVerification: () -> Unit = {})
suspend fun verifyPurchases(): Boolean
}

class PlayPaymentUseCase(private val paymentRepository: PaymentRepository) : PaymentUseCase {
Expand Down Expand Up @@ -56,24 +58,20 @@ 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()
}
}
}
.getOrNull() == VerificationResult.Success

private fun PurchaseResult?.shouldDelayLoading() =
this is PurchaseResult.FetchingProducts || this is PurchaseResult.VerificationStarted
Expand All @@ -99,7 +97,5 @@ class EmptyPaymentUseCase : PaymentUseCase {
// No op
}

override suspend fun verifyPurchases(onSuccessfulVerification: () -> Unit) {
// No op
}
override suspend fun verifyPurchases(): Boolean = false
}
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
Expand Up @@ -90,8 +90,9 @@ class AccountViewModel(

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ class ConnectViewModel(

init {
viewModelScope.launch {
paymentUseCase.verifyPurchases {
viewModelScope.launch { accountRepository.getAccountData() }
if (paymentUseCase.verifyPurchases()) {
accountRepository.getAccountData()
}
}
viewModelScope.launch { deviceRepository.updateDevice() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ class OutOfTimeViewModel(

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,9 @@ class WelcomeViewModel(

private fun verifyPurchases() {
viewModelScope.launch {
paymentUseCase.verifyPurchases()
updateAccountExpiry()
if (paymentUseCase.verifyPurchases()) {
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
16 changes: 16 additions & 0 deletions android/gradle/verification-metadata.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3242,6 +3242,22 @@
<sha256 value="1458ec6f2cff3f22df3f57ff712e93d15c2540ee242243e167ed6d9f106bfcf8" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.arrow-kt" name="arrow-resilience" version="1.2.3">
<artifact name="arrow-resilience-1.2.3.module">
<sha256 value="4ee897e34ac212dba6d1c8fcbb06192d25a3f4ba12b2893f37ca06bd928071ae" origin="Generated by Gradle"/>
</artifact>
<artifact name="arrow-resilience-metadata-1.2.3.jar">
<sha256 value="289d20131b1194d36d74dbe48dac5a274ab0e7f76383565547048f549e9ddf1f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.arrow-kt" name="arrow-resilience-jvm" version="1.2.3">
<artifact name="arrow-resilience-jvm-1.2.3.jar">
<sha256 value="002904da95b1ea17b9d5119920e8d76dbf43ce3cb5a12bb4a161b1978be48bab" origin="Generated by Gradle"/>
</artifact>
<artifact name="arrow-resilience-jvm-1.2.3.module">
<sha256 value="9559b8d788d46f9e8817e7f74bb6b72fdea8996adea6c6f7ac7887bd67856d77" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.github.davidburstrom.contester" name="contester-breakpoint" version="0.2.0">
<artifact name="contester-breakpoint-0.2.0.jar">
<sha256 value="672cbebb5d45a72b35dd81fd6127e187451bb6fb7fba35315bbdf2f57cfce835" origin="Generated by Gradle"/>
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 @@ -41,4 +41,5 @@ android {
dependencies {
implementation(Dependencies.Kotlin.stdlib)
implementation(Dependencies.KotlinX.coroutinesAndroid)
implementation(Dependencies.Arrow.core)
}
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 3373677

Please sign in to comment.