1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

DROID-2575 Membership | Billing purchases logic (#1274)

This commit is contained in:
Konstantin Ivanov 2024-06-06 12:56:27 +02:00 committed by GitHub
parent 91acfe5a66
commit 6a9644ee11
Signed by: github
GPG key ID: B5690EEEBB952194
8 changed files with 78 additions and 64 deletions

View file

@ -1,7 +1,6 @@
plugins {
id "com.android.library"
id "kotlin-android"
id "kotlinx-serialization"
}
android {
@ -48,8 +47,6 @@ dependencies {
implementation libs.timber
implementation libs.kotlinxSerializationJson
testImplementation libs.junit
testImplementation libs.kotlinTest
testImplementation libs.mockitoKotlin

View file

@ -1,9 +1,6 @@
package com.anytypeio.anytype.payments.mapping
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod.METHOD_CRYPTO
@ -22,6 +19,7 @@ import com.anytypeio.anytype.payments.constants.MembershipConstants.MEMBERSHIP_L
import com.anytypeio.anytype.payments.constants.MembershipConstants.PRIVACY_POLICY
import com.anytypeio.anytype.payments.constants.MembershipConstants.TERMS_OF_SERVICE
import com.anytypeio.anytype.payments.models.BillingPriceInfo
import com.anytypeio.anytype.payments.models.MembershipPurchase
import com.anytypeio.anytype.payments.models.Tier
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
@ -218,8 +216,12 @@ private fun MembershipTierData.mapActiveTierButtonAndNameStates(
0 -> TierButton.Manage.Android.Disabled to TierAnyName.Hidden
1 -> {
val purchase = purchases[0]
val purchaseModel = Json.decodeFromString<PurchaseModel>(purchase.originalJson)
if (purchaseModel.obfuscatedAccountId == accountId && purchaseModel.productId == androidProductId) {
val purchaseObfuscatedAccountId = purchase.accountId
val containsProduct = purchase.products.any { it == androidProductId }
if (purchaseObfuscatedAccountId == accountId
&& containsProduct
&& purchase.state == MembershipPurchase.PurchaseState.PURCHASED
) {
TierButton.Manage.Android.Enabled(androidProductId) to TierAnyName.Hidden
} else {
TierButton.Manage.Android.Disabled to TierAnyName.Hidden
@ -294,17 +296,18 @@ private fun handleNoPurchasesState(
private fun getButtonStateAccordingToPurchaseState(
androidProductId: String?,
accountId: String,
purchases: List<Purchase>
purchases: List<MembershipPurchase>
): TierButton {
return when (purchases.size) {
0 -> TierButton.Hidden
1 -> {
val purchase = purchases[0]
val purchaseModel = Json.decodeFromString<PurchaseModel>(purchase.originalJson)
val purchaseAccountId = purchase.accountId
val containsProduct = purchase.products.any { it == androidProductId }
when {
purchaseModel.obfuscatedAccountId != accountId ->
purchaseAccountId != accountId ->
TierButton.HiddenWithText.DifferentPurchaseAccountId
purchaseModel.productId != androidProductId ->
!containsProduct ->
TierButton.HiddenWithText.DifferentPurchaseProductId
else -> TierButton.Hidden
}
@ -443,8 +446,3 @@ private fun MembershipTierData.getTierEmail(isActive: Boolean, membershipEmail:
return TierEmail.Hidden
}
@Serializable
data class PurchaseModel(
val obfuscatedAccountId: String,
val productId: String
)

View file

@ -0,0 +1,34 @@
package com.anytypeio.anytype.payments.models
import com.android.billingclient.api.Purchase
import timber.log.Timber
data class MembershipPurchase(
val accountId: String,
val products: List<String>,
val state: PurchaseState
) {
enum class PurchaseState {
UNSPECIFIED_STATE,
PURCHASED,
PENDING
}
}
fun Purchase.toMembershipPurchase(): MembershipPurchase? {
val obfuscatedAccountId = accountIdentifiers?.obfuscatedAccountId
if (obfuscatedAccountId == null) {
Timber.e("Billing purchase does not have obfuscatedAccountId")
return null
}
return MembershipPurchase(
accountId = obfuscatedAccountId,
products = products,
state = when (purchaseState) {
Purchase.PurchaseState.PURCHASED -> MembershipPurchase.PurchaseState.PURCHASED
Purchase.PurchaseState.PENDING -> MembershipPurchase.PurchaseState.PENDING
else -> MembershipPurchase.PurchaseState.UNSPECIFIED_STATE
}
)
}

View file

@ -18,6 +18,8 @@ import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.payments.models.MembershipPurchase
import com.anytypeio.anytype.payments.models.toMembershipPurchase
import kotlin.math.min
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@ -330,12 +332,17 @@ class BillingClientLifecycle(
_subscriptionPurchases.emit(BillingPurchaseState.NoPurchases)
} else {
Timber.d("processPurchases: Subscription purchases found ${subscriptionPurchaseList[0]}")
_subscriptionPurchases.emit(
BillingPurchaseState.HasPurchases(
purchases = subscriptionPurchaseList,
isNewPurchase = isNewPurchase
val membershipPurchases = subscriptionPurchaseList.mapNotNull { it.toMembershipPurchase() }
if (membershipPurchases.isNotEmpty()) {
_subscriptionPurchases.emit(
BillingPurchaseState.HasPurchases(
purchases = membershipPurchases,
isNewPurchase = isNewPurchase
)
)
)
} else {
_subscriptionPurchases.emit(BillingPurchaseState.NoPurchases)
}
}
}
}
@ -386,6 +393,9 @@ sealed class BillingClientState {
sealed class BillingPurchaseState {
data object Loading : BillingPurchaseState()
data class HasPurchases(val purchases: List<Purchase>, val isNewPurchase: Boolean) : BillingPurchaseState()
data class HasPurchases(
val purchases: List<MembershipPurchase>,
val isNewPurchase: Boolean
) : BillingPurchaseState()
data object NoPurchases : BillingPurchaseState()
}

View file

@ -8,7 +8,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.core_models.membership.EmailVerificationStatus
import com.anytypeio.anytype.core_models.membership.MembershipErrors
@ -23,6 +22,7 @@ import com.anytypeio.anytype.domain.payments.VerifyMembershipEmailCode
import com.anytypeio.anytype.payments.constants.MembershipConstants.EXPLORER_ID
import com.anytypeio.anytype.payments.constants.MembershipConstants.MEMBERSHIP_NAME_MIN_LENGTH
import com.anytypeio.anytype.payments.mapping.toMainView
import com.anytypeio.anytype.payments.models.MembershipPurchase
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierEmail
@ -704,13 +704,13 @@ class MembershipViewModel(
* even if the Google account on this device has purchased the subscription.
* In this example, the method will return true.
*/
private fun deviceHasGooglePlaySubscription(purchases: List<Purchase>?, product: String) =
private fun deviceHasGooglePlaySubscription(purchases: List<MembershipPurchase>?, product: String) =
purchaseForProduct(purchases, product) != null
/**
* Return purchase for the provided Product, if it exists.
*/
private fun purchaseForProduct(purchases: List<Purchase>?, product: String): Purchase? {
private fun purchaseForProduct(purchases: List<MembershipPurchase>?, product: String): MembershipPurchase? {
purchases?.let {
for (purchase in it) {
if (purchase.products[0] == product) {

View file

@ -1,6 +1,7 @@
package com.anytypeio.anytype.payments
import app.cash.turbine.turbineScope
import com.android.billingclient.api.AccountIdentifiers
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.anytypeio.anytype.core_models.membership.Membership
@ -9,6 +10,7 @@ import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.payments.constants.MembershipConstants
import com.anytypeio.anytype.payments.models.BillingPriceInfo
import com.anytypeio.anytype.payments.models.MembershipPurchase
import com.anytypeio.anytype.payments.models.PeriodDescription
import com.anytypeio.anytype.payments.models.PeriodUnit
import com.anytypeio.anytype.payments.models.TierAnyName
@ -22,6 +24,7 @@ import com.anytypeio.anytype.payments.viewmodel.MembershipMainState
import com.anytypeio.anytype.payments.viewmodel.MembershipTierState
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
import java.lang.reflect.Member
import kotlin.test.assertIs
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
@ -32,6 +35,7 @@ import org.junit.Test
import org.mockito.Mockito
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.stub
import org.mockito.kotlin.whenever
class TierActiveWithDifferentSubIdTest : MembershipTestsSetup() {
@ -98,12 +102,7 @@ class TierActiveWithDifferentSubIdTest : MembershipTestsSetup() {
stubBilling(billingClientState = BillingClientState.Connected(listOf(product1)))
// Mocking purchase
val purchase = Mockito.mock(Purchase::class.java)
Mockito.`when`(purchase.products).thenReturn(listOf(androidProductId))
Mockito.`when`(purchase.isAcknowledged).thenReturn(true)
val purchaseJson =
"{\"obfuscatedAccountId\":\"$accountIdDifferent\", \"productId\":\"$androidProductId\"}"
Mockito.`when`(purchase.originalJson).thenReturn(purchaseJson)
val purchase = MembershipPurchase(accountIdDifferent, listOf(androidProductId), MembershipPurchase.PurchaseState.PURCHASED)
stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase), false))
// Mocking the flow of membership status
@ -224,12 +223,7 @@ class TierActiveWithDifferentSubIdTest : MembershipTestsSetup() {
)
)
val purchase = Mockito.mock(Purchase::class.java)
Mockito.`when`(purchase.products).thenReturn(listOf(invalidProductId))
Mockito.`when`(purchase.isAcknowledged).thenReturn(true)
val purchaseJson =
"{\"obfuscatedAccountId\":\"$accountId\", \"productId\":\"$invalidProductId\"}"
Mockito.`when`(purchase.originalJson).thenReturn(purchaseJson)
val purchase = MembershipPurchase(accountId, listOf(invalidProductId), MembershipPurchase.PurchaseState.PURCHASED)
stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase), false))
val flow = flow {
@ -431,20 +425,8 @@ class TierActiveWithDifferentSubIdTest : MembershipTestsSetup() {
)
)
val purchase1 = Mockito.mock(Purchase::class.java)
Mockito.`when`(purchase1.products).thenReturn(listOf(invalidProductId))
Mockito.`when`(purchase1.isAcknowledged).thenReturn(true)
val purchaseJson1 =
"{\"obfuscatedAccountId\":\"$accountId\", \"productId\":\"$invalidProductId\"}"
Mockito.`when`(purchase1.originalJson).thenReturn(purchaseJson1)
val purchase2 = Mockito.mock(Purchase::class.java)
Mockito.`when`(purchase2.products).thenReturn(listOf(invalidProductId))
Mockito.`when`(purchase2.isAcknowledged).thenReturn(true)
val purchaseJson2 =
"{\"obfuscatedAccountId\":\"$accountId\", \"productId\":\"$androidProductId\"}"
Mockito.`when`(purchase2.originalJson).thenReturn(purchaseJson2)
val purchase1 = MembershipPurchase(accountId, listOf(invalidProductId), MembershipPurchase.PurchaseState.PURCHASED)
val purchase2 = MembershipPurchase(accountId, listOf(androidProductId), MembershipPurchase.PurchaseState.PURCHASED)
stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase1, purchase2), false))
val flow = flow {

View file

@ -7,6 +7,7 @@ import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.payments.constants.MembershipConstants
import com.anytypeio.anytype.payments.models.MembershipPurchase
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierConditionInfo
@ -208,12 +209,7 @@ class TierAndroidActiveTests : MembershipTestsSetup() {
val (features, tiers) = commonTestSetup()
stubBilling()
val purchase = Mockito.mock(Purchase::class.java)
Mockito.`when`(purchase.products).thenReturn(listOf(androidProductId))
Mockito.`when`(purchase.isAcknowledged).thenReturn(true)
val purchaseJson =
"{\"obfuscatedAccountId\":\"$accountId\", \"productId\":\"$androidProductId\"}"
Mockito.`when`(purchase.originalJson).thenReturn(purchaseJson)
val purchase = MembershipPurchase(accountId, listOf(androidProductId), MembershipPurchase.PurchaseState.PURCHASED)
stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase), false))
stubMembershipProvider(setupMembershipStatus(tiers))

View file

@ -10,6 +10,7 @@ import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.payments.constants.MembershipConstants.BUILDER_ID
import com.anytypeio.anytype.payments.constants.MembershipConstants.EXPLORER_ID
import com.anytypeio.anytype.payments.models.BillingPriceInfo
import com.anytypeio.anytype.payments.models.MembershipPurchase
import com.anytypeio.anytype.payments.models.PeriodDescription
import com.anytypeio.anytype.payments.models.PeriodUnit
import com.anytypeio.anytype.payments.models.TierAnyName
@ -84,12 +85,8 @@ class TierBuilderFallbackOnExplorerTest : MembershipTestsSetup() {
Mockito.`when`(pricingPhaseList[0]?.billingPeriod).thenReturn("P1Y")
stubBilling(billingClientState = BillingClientState.Connected(listOf(product)))
val purchase = Mockito.mock(Purchase::class.java)
Mockito.`when`(purchase.products).thenReturn(listOf(androidProductId))
Mockito.`when`(purchase.isAcknowledged).thenReturn(true)
val purchaseJson =
"{\"obfuscatedAccountId\":\"$accountId\", \"productId\":\"$androidProductId\"}"
Mockito.`when`(purchase.originalJson).thenReturn(purchaseJson)
val purchase = MembershipPurchase(accountId, listOf(androidProductId), MembershipPurchase.PurchaseState.PURCHASED)
stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase), false))
val flow = flow {
emit(