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

DROID-2555 Membership | Fix | CoCreator tier (#1256)

This commit is contained in:
Konstantin Ivanov 2024-06-04 15:21:19 +02:00 committed by GitHub
parent 7284e8ab80
commit e0c7c666cd
Signed by: github
GPG key ID: B5690EEEBB952194
4 changed files with 327 additions and 169 deletions

View file

@ -6,11 +6,17 @@ 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
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod.METHOD_INAPP_APPLE
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod.METHOD_INAPP_GOOGLE
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod.METHOD_NONE
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod.METHOD_STRIPE
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.payments.constants.MembershipConstants
import com.anytypeio.anytype.payments.constants.MembershipConstants.ACTIVE_TIERS_WITH_BANNERS
import com.anytypeio.anytype.payments.constants.MembershipConstants.CO_CREATOR_ID
import com.anytypeio.anytype.payments.constants.MembershipConstants.MEMBERSHIP_CONTACT_EMAIL
import com.anytypeio.anytype.payments.constants.MembershipConstants.MEMBERSHIP_LEVEL_DETAILS
import com.anytypeio.anytype.payments.constants.MembershipConstants.PRIVACY_POLICY
@ -34,11 +40,7 @@ fun MembershipStatus.toMainView(
billingPurchaseState: BillingPurchaseState,
accountId: String
): MembershipMainState {
val (showBanner, subtitle) = if (activeTier.value in ACTIVE_TIERS_WITH_BANNERS) {
true to R.string.payments_subheader
} else {
false to null
}
val (showBanner, subtitle) = determineBannerAndSubtitle()
return MembershipMainState.Default(
title = R.string.payments_header,
subtitle = subtitle,
@ -65,19 +67,21 @@ fun MembershipStatus.toMainView(
)
}
private fun MembershipStatus.isTierActive(tierId: Int): Boolean {
return when (this.status) {
Membership.Status.STATUS_ACTIVE -> activeTier.value == tierId
else -> false
private fun MembershipStatus.determineBannerAndSubtitle(): Pair<Boolean, Int?> {
return if (activeTier.value in ACTIVE_TIERS_WITH_BANNERS) {
true to R.string.payments_subheader
} else {
false to null
}
}
private fun MembershipStatus.isTierActive(tierId: Int): Boolean {
return status == Membership.Status.STATUS_ACTIVE && activeTier.value == tierId
}
private fun MembershipTierData.isActiveTierPurchasedOnAndroid(activePaymentMethod: MembershipPaymentMethod): Boolean {
val androidProductId = this.androidProductId
return when (activePaymentMethod) {
MembershipPaymentMethod.METHOD_INAPP_GOOGLE -> return !androidProductId.isNullOrBlank()
else -> false
}
return activePaymentMethod == METHOD_INAPP_GOOGLE && !androidProductId.isNullOrBlank()
}
fun MembershipTierData.toView(
@ -86,15 +90,19 @@ fun MembershipTierData.toView(
billingPurchaseState: BillingPurchaseState,
accountId: String
): Tier {
val tierId = TierId(id)
val isActive = membershipStatus.isTierActive(id)
val emailState = getTierEmail(isActive, membershipStatus.userEmail)
val tierName = name
val tierDescription = description
val result = Tier(
id = tierId,
title = tierName,
subtitle = tierDescription,
val (buttonState, anyNameState) = mapButtonAndNameStates(
isActive = isActive,
billingPurchaseState = billingPurchaseState,
membershipStatus = membershipStatus,
accountId = accountId,
billingClientState = billingClientState
)
return Tier(
id = TierId(id),
title = name,
subtitle = description,
conditionInfo = getConditionInfo(
isActive = isActive,
billingClientState = billingClientState,
@ -103,19 +111,9 @@ fun MembershipTierData.toView(
),
isActive = isActive,
features = features,
membershipAnyName = getAnyName(
isActive = isActive,
billingClientState = billingClientState,
membershipStatus = membershipStatus,
billingPurchaseState = billingPurchaseState
),
buttonState = toButtonView(
isActive = isActive,
billingPurchaseState = billingPurchaseState,
membershipStatus = membershipStatus,
accountId = accountId
),
email = emailState,
membershipAnyName = anyNameState,
buttonState = buttonState,
email = getTierEmail(isActive, membershipStatus.userEmail),
color = colorStr,
urlInfo = androidManageUrl,
stripeManageUrl = stripeManageUrl,
@ -124,7 +122,6 @@ fun MembershipTierData.toView(
androidProductId = androidProductId,
paymentMethod = membershipStatus.paymentMethod
)
return result
}
fun MembershipTierData.toPreviewView(
@ -151,87 +148,147 @@ fun MembershipTierData.toPreviewView(
)
}
private fun MembershipTierData.toButtonView(
private fun MembershipTierData.mapButtonAndNameStates(
isActive: Boolean,
billingClientState: BillingClientState,
billingPurchaseState: BillingPurchaseState,
membershipStatus: MembershipStatus,
accountId: String
): TierButton {
val androidProductId = this.androidProductId
val androidInfoUrl = this.androidManageUrl
): Pair<TierButton, TierAnyName> {
if (membershipStatus.isPending()) {
return TierButton.Hidden to TierAnyName.Hidden
}
return if (isActive) {
val wasPurchasedOnAndroid = isActiveTierPurchasedOnAndroid(membershipStatus.paymentMethod)
if (!wasPurchasedOnAndroid) {
if (id == MembershipConstants.EXPLORER_ID) {
if (membershipStatus.userEmail.isBlank()) {
TierButton.Submit.Enabled
} else {
TierButton.ChangeEmail
}
} else {
when (membershipStatus.paymentMethod) {
MembershipPaymentMethod.METHOD_NONE,
MembershipPaymentMethod.METHOD_CRYPTO -> {
TierButton.Hidden
}
MembershipPaymentMethod.METHOD_STRIPE -> {
if (stripeManageUrl.isNullOrBlank()) {
TierButton.Hidden
} else {
TierButton.Manage.External.Enabled(stripeManageUrl)
}
}
MembershipPaymentMethod.METHOD_INAPP_APPLE -> {
if (iosManageUrl.isNullOrBlank()) {
TierButton.Hidden
} else {
TierButton.Manage.External.Enabled(iosManageUrl)
}
}
MembershipPaymentMethod.METHOD_INAPP_GOOGLE -> TierButton.Manage.External.Enabled(
androidInfoUrl
)
}
}
} else {
if (billingPurchaseState is BillingPurchaseState.HasPurchases) {
TierButton.Manage.Android.Enabled(androidProductId)
} else {
TierButton.Manage.Android.Disabled
}
}
mapActiveTierButtonAndNameStates(
billingPurchaseState = billingPurchaseState,
paymentMethod = membershipStatus.paymentMethod,
userEmail = membershipStatus.userEmail,
accountId = accountId
)
} else {
if (androidProductId == null) {
if (androidInfoUrl == null) {
TierButton.Info.Disabled
} else {
TierButton.Info.Enabled(androidInfoUrl)
mapInactiveTierButtonAndNameStates(
billingClientState = billingClientState,
billingPurchaseState = billingPurchaseState,
membershipStatus = membershipStatus,
accountId = accountId
)
}
}
private fun MembershipStatus.isPending(): Boolean {
return status == Membership.Status.STATUS_PENDING || status == Membership.Status.STATUS_PENDING_FINALIZATION
}
private fun MembershipTierData.mapActiveTierButtonAndNameStates(
billingPurchaseState: BillingPurchaseState,
paymentMethod: MembershipPaymentMethod,
userEmail: String,
accountId: String
): Pair<TierButton, TierAnyName> {
val wasPurchasedOnAndroid = isActiveTierPurchasedOnAndroid(paymentMethod)
if (!wasPurchasedOnAndroid) {
return when {
id == MembershipConstants.EXPLORER_ID && userEmail.isBlank() -> {
TierButton.Submit.Enabled to TierAnyName.Hidden
}
} else {
when (billingPurchaseState) {
is BillingPurchaseState.HasPurchases -> {
getButtonStateAccordingToPurchaseState(
androidProductId = androidProductId,
accountId = accountId,
purchases = billingPurchaseState.purchases
)
}
BillingPurchaseState.Loading -> {
TierButton.Hidden
}
BillingPurchaseState.NoPurchases -> {
if (membershipStatus.anyName.isBlank()) {
TierButton.Pay.Disabled
} else {
TierButton.Pay.Enabled
}
}
id == MembershipConstants.EXPLORER_ID -> {
TierButton.ChangeEmail to TierAnyName.Hidden
}
paymentMethod == METHOD_NONE || paymentMethod == METHOD_CRYPTO -> {
TierButton.Hidden to TierAnyName.Hidden
}
paymentMethod == METHOD_STRIPE && !stripeManageUrl.isNullOrBlank() -> {
TierButton.Manage.External.Enabled(stripeManageUrl) to TierAnyName.Hidden
}
paymentMethod == METHOD_INAPP_APPLE && !iosManageUrl.isNullOrBlank() -> {
TierButton.Manage.External.Enabled(iosManageUrl) to TierAnyName.Hidden
}
paymentMethod == METHOD_INAPP_GOOGLE -> {
TierButton.Manage.External.Enabled(androidManageUrl) to TierAnyName.Hidden
}
else -> {
TierButton.Hidden to TierAnyName.Hidden
}
}
}
return if (billingPurchaseState is BillingPurchaseState.HasPurchases) {
val purchases = billingPurchaseState.purchases
return when (purchases.size) {
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) {
TierButton.Manage.Android.Enabled(androidProductId) to TierAnyName.Hidden
} else {
TierButton.Manage.Android.Disabled to TierAnyName.Hidden
}
}
else -> TierButton.Manage.Android.Disabled to TierAnyName.Hidden
}
} else {
TierButton.Manage.Android.Disabled to TierAnyName.Hidden
}
}
private fun MembershipTierData.mapInactiveTierButtonAndNameStates(
billingClientState: BillingClientState,
billingPurchaseState: BillingPurchaseState,
membershipStatus: MembershipStatus,
accountId: String
): Pair<TierButton, TierAnyName> {
val androidProductId = this.androidProductId
val androidInfoUrl = this.androidManageUrl
if (androidProductId == null) {
return if (androidInfoUrl == null) {
TierButton.Info.Disabled to TierAnyName.Hidden
} else {
TierButton.Info.Enabled(androidInfoUrl) to TierAnyName.Hidden
}
}
if (membershipStatus.activeTier.value == CO_CREATOR_ID) {
return TierButton.Hidden to TierAnyName.Hidden
}
return when (billingPurchaseState) {
is BillingPurchaseState.HasPurchases -> {
getButtonStateAccordingToPurchaseState(
androidProductId = androidProductId,
accountId = accountId,
purchases = billingPurchaseState.purchases
) to TierAnyName.Hidden
}
BillingPurchaseState.Loading -> {
TierButton.Hidden to TierAnyName.Hidden
}
BillingPurchaseState.NoPurchases -> {
handleNoPurchasesState(
billingClientState = billingClientState,
membershipStatus = membershipStatus,
androidProductId = androidProductId
)
}
}
}
private fun handleNoPurchasesState(
billingClientState: BillingClientState,
membershipStatus: MembershipStatus,
androidProductId: String
): Pair<TierButton, TierAnyName> {
if (billingClientState is BillingClientState.Connected) {
val product = billingClientState.productDetails.find { it.productId == androidProductId }
return when {
product == null -> TierButton.Pay.Disabled to TierAnyName.Visible.Disabled
product.billingPriceInfo() == null -> TierButton.Pay.Disabled to TierAnyName.Visible.Disabled
membershipStatus.anyName.isBlank() -> TierButton.Pay.Disabled to TierAnyName.Visible.Enter
else -> TierButton.Pay.Enabled to TierAnyName.Visible.Purchased(membershipStatus.anyName)
}
}
return TierButton.Pay.Disabled to TierAnyName.Visible.Disabled
}
private fun getButtonStateAccordingToPurchaseState(
@ -239,71 +296,20 @@ private fun getButtonStateAccordingToPurchaseState(
accountId: String,
purchases: List<Purchase>
): TierButton {
when (purchases.size) {
0 -> {
return TierButton.Hidden
}
return when (purchases.size) {
0 -> TierButton.Hidden
1 -> {
val purchase = purchases[0]
val purchaseModel = Json.decodeFromString<PurchaseModel>(purchase.originalJson)
if (purchaseModel.obfuscatedAccountId != accountId) {
return TierButton.HiddenWithText.DifferentPurchaseAccountId
}
if (purchaseModel.productId != androidProductId) {
return TierButton.HiddenWithText.DifferentPurchaseProductId
}
return TierButton.Hidden
}
else -> {
return TierButton.HiddenWithText.MoreThenOnePurchase
}
}
}
private fun MembershipTierData.getAnyName(
isActive: Boolean,
billingClientState: BillingClientState,
membershipStatus: MembershipStatus,
billingPurchaseState: BillingPurchaseState
): TierAnyName {
if (isActive) {
return TierAnyName.Hidden
} else {
if (androidProductId == null) {
return TierAnyName.Hidden
} else {
if (membershipStatus.status == Membership.Status.STATUS_PENDING ||
membershipStatus.status == Membership.Status.STATUS_PENDING_FINALIZATION
) {
return TierAnyName.Hidden
}
if (billingPurchaseState is BillingPurchaseState.Loading
|| billingPurchaseState is BillingPurchaseState.HasPurchases
) {
return TierAnyName.Hidden
}
if (billingClientState is BillingClientState.Connected) {
val product =
billingClientState.productDetails.find { it.productId == androidProductId }
if (product == null) {
return TierAnyName.Visible.Disabled
} else {
if (product.billingPriceInfo() == null) {
return TierAnyName.Visible.Disabled
} else {
if (membershipStatus.anyName.isBlank()) {
return TierAnyName.Visible.Enter
} else {
return TierAnyName.Visible.Purchased(membershipStatus.anyName)
}
}
}
} else {
return TierAnyName.Visible.Disabled
when {
purchaseModel.obfuscatedAccountId != accountId ->
TierButton.HiddenWithText.DifferentPurchaseAccountId
purchaseModel.productId != androidProductId ->
TierButton.HiddenWithText.DifferentPurchaseProductId
else -> TierButton.Hidden
}
}
else -> TierButton.HiddenWithText.MoreThenOnePurchase
}
}
@ -382,11 +388,9 @@ private fun MembershipTierData.createConditionInfoForBillingTier(
BillingClientState.Loading -> {
TierConditionInfo.Visible.LoadingBillingClient
}
is BillingClientState.Error -> {
TierConditionInfo.Visible.Error(billingClientState.message)
}
is BillingClientState.Connected -> {
val product =
billingClientState.productDetails.find { it.productId == androidProductId }

View file

@ -0,0 +1,151 @@
package com.anytypeio.anytype.payments
import app.cash.turbine.turbineScope
import com.android.billingclient.api.ProductDetails
import com.anytypeio.anytype.core_models.membership.Membership
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.BillingPriceInfo
import com.anytypeio.anytype.payments.models.PeriodDescription
import com.anytypeio.anytype.payments.models.PeriodUnit
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierConditionInfo
import com.anytypeio.anytype.payments.models.TierEmail
import com.anytypeio.anytype.payments.models.TierPreview
import com.anytypeio.anytype.payments.playbilling.BillingClientState
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 junit.framework.TestCase
import kotlin.test.assertIs
import kotlinx.coroutines.test.runTest
import net.bytebuddy.utility.RandomString
import org.junit.Test
import org.mockito.Mockito
class CocreatorActiveTest : MembershipTestsSetup() {
private fun commonTestSetup(): Pair<List<String>, List<MembershipTierData>> {
val features = listOf("feature-${RandomString.make()}", "feature-${RandomString.make()}")
val tiers = setupTierData(features)
return Pair(features, tiers)
}
private fun setupTierData(features: List<String>): List<MembershipTierData> {
return listOf(
StubMembershipTierData(
id = MembershipConstants.EXPLORER_ID,
androidProductId = null,
features = features,
periodType = MembershipPeriodType.PERIOD_TYPE_UNLIMITED,
priceStripeUsdCents = 0
),
StubMembershipTierData(
id = MembershipConstants.BUILDER_ID,
androidProductId = androidProductId,
features = features,
periodValue = 1,
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 9900
),
StubMembershipTierData(
id = MembershipConstants.CO_CREATOR_ID,
androidProductId = null,
features = features,
periodValue = 3,
periodType = MembershipPeriodType.PERIOD_TYPE_YEARS,
priceStripeUsdCents = 29900
),
StubMembershipTierData(
id = 22,
androidProductId = null,
features = features,
periodValue = 1,
periodType = MembershipPeriodType.PERIOD_TYPE_MONTHS,
priceStripeUsdCents = 1000
)
)
}
private fun setupMembershipStatus(
tiers: List<MembershipTierData>,
anyName: String = "",
status : Membership.Status = Membership.Status.STATUS_ACTIVE
): MembershipStatus {
return MembershipStatus(
activeTier = TierId(MembershipConstants.CO_CREATOR_ID),
status = status,
dateEnds = 1714199910,
paymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName = anyName,
tiers = tiers,
formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
)
}
@Test
fun `when co-creator is active`() = runTest {
turbineScope {
val (features, tiers) = commonTestSetup()
stubPurchaseState()
stubMembershipProvider(setupMembershipStatus(tiers))
val product = Mockito.mock(ProductDetails::class.java)
val subscriptionOfferDetails = listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
val pricingPhases = Mockito.mock(ProductDetails.PricingPhases::class.java)
val pricingPhaseList = listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
Mockito.`when`(product.productId).thenReturn(androidProductId)
Mockito.`when`(product?.subscriptionOfferDetails).thenReturn(subscriptionOfferDetails)
Mockito.`when`(subscriptionOfferDetails[0].pricingPhases).thenReturn(pricingPhases)
Mockito.`when`(pricingPhases?.pricingPhaseList).thenReturn(pricingPhaseList)
val formattedPrice = "$9.99" // You can set any desired formatted price here
Mockito.`when`(pricingPhaseList[0]?.formattedPrice).thenReturn(formattedPrice)
Mockito.`when`(pricingPhaseList[0]?.billingPeriod).thenReturn("P1Y")
stubBilling(billingClientState = BillingClientState.Connected(listOf(product)))
val viewModel = buildViewModel()
val viewStateFlow = viewModel.viewState.testIn(backgroundScope)
val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
assertIs<MembershipMainState.Loading>(viewStateFlow.awaitItem())
assertIs<MembershipTierState.Hidden>(tierStateFlow.awaitItem())
val expectedConditionInfo = TierConditionInfo.Visible.PriceBilling(
BillingPriceInfo(
formattedPrice = formattedPrice,
period = PeriodDescription(amount = 1, unit = PeriodUnit.YEARS)
)
)
viewStateFlow.awaitItem().let { result ->
assertIs<MembershipMainState.Default>(result)
val tier: TierPreview = result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!!
TestCase.assertEquals(MembershipConstants.BUILDER_ID, tier.id.value)
TestCase.assertEquals(false, tier.isActive)
TestCase.assertEquals(expectedConditionInfo, tier.conditionInfo)
}
viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
tierStateFlow.awaitItem().let { result ->
assertIs<MembershipTierState.Visible>(result)
validateTierView(
tier = result.tier,
expectedFeatures = features,
expectedConditionInfo = expectedConditionInfo,
expectedAnyName = TierAnyName.Hidden,
expectedButtonState = TierButton.Hidden,
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = false,
expectedEmailState = TierEmail.Hidden
)
}
}
}
}

View file

@ -211,6 +211,9 @@ class TierAndroidActiveTests : MembershipTestsSetup() {
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)
stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase), false))
stubMembershipProvider(setupMembershipStatus(tiers))

View file

@ -597,7 +597,7 @@ class TierAndroidNotActiveTests : MembershipTestsSetup() {
expectedFeatures = features,
expectedConditionInfo = expectedConditionInfo,
expectedAnyName = TierAnyName.Hidden,
expectedButtonState = TierButton.Pay.Disabled,
expectedButtonState = TierButton.Hidden,
expectedId = MembershipConstants.BUILDER_ID,
expectedActive = false,
expectedEmailState = TierEmail.Hidden